Français
Écrire un nœud
Un nœud, c’est une définition déclarative et une fonction. La définition génère le formulaire de l’éditeur, la validation du workflow, l’entrée de la palette et la page du nœud dans cette documentation. La fonction, execute, fait le travail quand une étape d’une exécution atteint le nœud.
Tous les nœuds du catalogue suivent le même contrat, déclaré avec defineNode dans packages/workflow/src/nodes/contract.ts, avec des paramètres déclarés comme le décrit packages/workflow/src/nodes/params.ts. Les nœuds vivent dans packages/nodes/src/catalog/, un fichier par nœud ou par famille de nœuds. Cette page prend pour fil conducteur le vrai nœud Marquer (mail.flag, fichier packages/nodes/src/catalog/mail-flag.ts).
Si le nœud parle à un service tiers au moyen d’une clé d’API, lisez aussi Écrire une intégration : les intégrations ont leur propre recette, qui s’ajoute à celle-ci.
La définition en un coup d’œil
defineNode(input) valide la déclaration et rend une NodeDefinition. L’entrée comporte ces champs :
| Champ | Requis | Rôle |
|---|---|---|
type | oui | L’identifiant au catalogue, famille.action (mail.flag, ai.categorize). |
version | oui | Un entier positif. Au catalogue, il vaut toujours 1 (voir plus bas). |
meta | oui | Noms, description, icône, groupe, catégorie de palette, alias de recherche, en français et en anglais. |
params | oui | La liste des déclarations de paramètres (ParamSpec[]). |
ports | oui | Ports d’entrée, ports de sortie (liste fixe ou fonction des paramètres), ports de service facultatifs, sortie de fournisseur, corps de boucle. |
policy | oui | La classe d’effet, plus des défauts facultatifs de rejeu, de nouvelles tentatives et de délai. |
requires | non | 'email' quand le nœud agit sur le mail déclencheur. |
execute(context) | oui | Une fonction async qui rend un NodeResult. |
Une déclaration invalide lève une NodeDefinitionError au chargement du catalogue, avec la liste des problèmes trouvés. Ce sont des erreurs de build, pas d’exécution : un type mal formé, un nom vide dans une langue, un groupe ou un effet inconnu, un ports.in vide sur une étape, un nom de port invalide, un policy.retry dont maxAttempts est inférieur à 1, un timeoutMs non positif, ou une déclaration de paramètre invalide.
La définition rendue ajoute des champs dérivés dont se servent l’éditeur, le validateur et le moteur : key (mail.flag@v1), category, servicePorts, isServiceProvider, bodyPort, et les fonctions outputPorts(params), defaults(context), initialParams() et validateParams(params, context).
Type et version
Le type doit suivre la forme famille.action : lettres minuscules et chiffres, segments séparés par des points, un tiret bas permis à l’intérieur d’un segment mais pas à son début ni à sa fin. attachment.extract_text est valide ; Mail.Flag, mail-flag et mail._flag ne le sont pas. La clé de registre est type@v<version>, par exemple mail.flag@v1.
Le catalogue garde exactement une version de chaque nœud, et c’est la version 1 : un nœud se modifie sur place. Le test du catalogue (packages/nodes/src/catalog/catalog.test.ts) échoue si deux définitions partagent un type ou si une version ne vaut pas 1. Un workflow enregistré contre un catalogue qui a changé depuis est refusé avec workflow.graph_outdated ; il n’est jamais réparé en silence.
Natures de nœuds : étape, déclencheur, fournisseur
Il n’existe pas de champ kind. La nature d’un nœud découle de sa déclaration, et la documentation générée la dit de la même façon.
| Nature | Comment elle se déclare | Ce qu’elle fait |
|---|---|---|
| Étape | Tout ce qui n’est ni l’une ni l’autre des deux suivantes. ports.in vaut ['main']. | S’exécute comme étape d’une exécution : execute est appelée. |
| Déclencheur | meta.group: 'trigger', ports.in: [], une seule sortie main, policy.effect: 'none'. Les types de déclencheurs sont aussi listés dans packages/workflow/src/graph/triggers.ts. | Lance des exécutions. N’est jamais exécuté comme étape : son execute rejette toujours. |
| Fournisseur | ports.provides est déclaré (par exemple { name: 'model', kind: 'llm.model' }) ; ports.in et ports.out sont vides. | Jamais une étape, jamais exécuté, ne publie aucune donnée. Il se branche sur le port de service d’un autre nœud, comme le port model d’un nœud IA. |
La plupart des contributions sont des étapes. Les déclencheurs dépendent de mécanismes du serveur (distribution des mails, planifications, webhooks, sondages) ; pour un déclencheur par sondage d’une intégration, suivez Écrire une intégration.
meta : noms, icône, groupe, catégorie, recherche
meta porte tout ce que montrent la palette et la page du nœud. Tous les textes sont des objets { fr, en }.
| Champ | Règle |
|---|---|
name | Requis, non vide dans les deux langues. |
description | Une ou deux phrases, dans les deux langues. Le contrat permet de l’omettre, mais le test du catalogue et le générateur de documentation l’exigent. |
icon | Requis. Le nom d’une icône du design system, jamais un SVG. |
group | trigger, logic, ai, mail, data, integration ou flow. La famille technique, utilisée par la politique de nœuds de l’organisation. |
category | declencheurs, ia, logique, donnees ou actions. La section de palette. Facultative dans le contrat (elle se déduit du groupe), mais le test du catalogue exige qu’elle soit déclarée explicitement. |
searchAliases | { fr: string[], en: string[] }. Le test du catalogue exige au moins quatre alias par langue, en minuscules, non vides et sans doublon. |
Marquer déclare :
ts
meta: {
name: { fr: 'Marquer', en: 'Flag' },
description: {
fr: 'Change l’état du mail déclencheur : lu/non-lu, épinglé/non épinglé. Seuls les états renseignés sont modifiés.',
en: 'Changes the state of the triggering email: read/unread, flagged/unflagged. Only the states you set are changed.',
},
icon: 'flag',
group: 'mail',
category: 'actions',
searchAliases: {
fr: ['marquer', 'lu', 'non lu', 'épingler', 'important', 'suivi', 'drapeau', 'étoile'],
en: ['flag', 'mark', 'read', 'unread', 'important', 'star', 'pin', 'follow up'],
},
},Paramètres
Les paramètres se déclarent dans params, sous forme de liste de ParamSpec. La déclaration pilote le formulaire, la validation et les défauts ; libellés et descriptions vivent dans la déclaration elle-même, dans les deux langues.
Champs communs à tous les types :
| Champ | Rôle |
|---|---|
name | La clé dans l’objet params du nœud. Unique à son niveau. |
label | { fr, en }, requis. |
description | { fr, en }, affichée en aide. |
required | La valeur doit être renseignée. |
requiredFor | Une fonction pure du contexte du graphe qui remplace required quand le contexte est connu (par exemple : un champ requis seulement quand aucun déclencheur n’apporte de mail). |
templatable | {{ }} est-il accepté ? Les types texte (string, text, valeurs d’un keyValue) l’acceptent par défaut ; les autres types exigent templatable: true. templatable: false sur un champ texte est un refus explicite. |
displayIf | Affichage conditionnel, à partir d’opérateurs comme eq, neq, in, notIn, exists, empty, combinés par all / any. Un paramètre masqué n’est ni requis ni validé, et sa valeur est conservée. |
requiresEmailFor | Pour un paramètre de premier niveau de type boolean, options ou multiOptions : les valeurs qui n’ont de sens qu’avec un mail déclencheur. La validation refuse de publier un graphe qui n’en a pas. |
Types et champs propres :
| Type | Valeur | Champs propres |
|---|---|---|
string, text | Une chaîne (text est multiligne). | default, defaultFor, placeholder, maxLength, suggestions |
number | Un nombre. | default, defaultFor, min, max, integer |
boolean | true / false. | default, defaultFor |
options | Une valeur parmi options. | options (value, label, description), default, defaultFor |
multiOptions | Plusieurs valeurs parmi options. | options, default |
collection | Une liste d’objets dont les champs sont eux-mêmes des ParamSpec. | of, minItems, maxItems, default |
keyValue | Une liste de { key, value }. | default |
conditions | Un arbre de conditions sur le message (le même que celui du déclencheur mail). | scope, default |
credential | L’identifiant opaque d’une connexion. Jamais templatable. | credentialType, capability, provider |
resourceLocator | Une ressource distante (dossier, agenda, table) choisie dans une liste, par identifiant ou par URL. | resource, modes, credentialParam, parentParam |
default est la valeur quand le contexte du graphe est inconnu. defaultFor, fonction pure du contexte du graphe (les types de déclencheurs, et le fait que les exécutions portent ou non un mail), la remplace quand le contexte est connu. Un défaut contextuel n’est jamais écrit à la création du nœud : il suit donc le graphe si le déclencheur change.
execute reçoit des paramètres déjà résolus : expressions rendues, défauts appliqués. Lisez-les avec les fonctions de packages/nodes/src/catalog/read-params.ts (par exemple readOptionalBoolean, qui accepte aussi un booléen rendu en texte par une expression) plutôt que de vous fier à leur type.
Marquer a deux booléens facultatifs et, délibérément, aucun défaut : une valeur absente signifie « ne pas y toucher ».
ts
const PARAMS: readonly ParamSpec[] = [
{
name: 'seen',
type: 'boolean',
label: { fr: 'Marquer comme lu', en: 'Mark as read' },
description: {
fr: 'Coché : le mail passe en lu. Décoché : il repasse en non-lu. Non renseigné : inchangé.',
en: 'Checked: mark as read. Unchecked: mark as unread. Left empty: unchanged.',
},
},
{
name: 'flagged',
type: 'boolean',
label: { fr: 'Épingler', en: 'Flag' },
description: { fr: '…', en: '…' },
},
];Ports
ports déclare comment le nœud se relie aux autres sur le canvas.
| Champ | Rôle |
|---|---|
in | Les ports d’entrée. ['main'] (la constante MAIN_PORT) pour toute étape ; [] pour les déclencheurs et les fournisseurs. |
out | Les ports de sortie : une liste fixe ([MAIN_PORT], ou ['item', 'done'] pour la boucle), ou une fonction pure des paramètres. Catégoriser, par exemple, calcule une sortie par catégorie. La fonction est évaluée par l’éditeur à chaque frappe : elle doit être totale, sans horloge, sans aléa ni effet de bord ; les noms invalides et les doublons sont écartés. |
services | Les ports d’entrée de service, comme model de nature llm.model sur les nœuds IA. Rien n’y transite pendant l’exécution : le moteur lit le câblage et injecte le service correspondant. Non branché signifie « utiliser les défauts de l’instance ». |
provides | Fait du nœud un fournisseur (voir plus haut). |
body | Le port de sortie qui ouvre un corps de boucle. Seul le nœud de boucle le déclare. |
Un nom de port doit être non vide, sans espace au début ni à la fin, imprimable, et faire au plus 200 caractères.
Marquer a une entrée et une sortie : ports: { in: [MAIN_PORT], out: [MAIN_PORT] }.
Politique : effet, rejeu, nouvelles tentatives, délai
policy dit au moteur à quel point le nœud est dangereux. Elle pilote les essais, le coupe-circuit et la politique de nœuds de l’organisation.
| Champ | Valeurs | Sens |
|---|---|---|
effect | none, external_write, send | Requis et honnête. none : pur (IA, transformation, condition). external_write : écrit hors du produit (classer, étiqueter, écriture HTTP, création d’un enregistrement). send : envoie un mail. Un nœud qui peut écrire le déclare, même si la plupart de ses actions ne font que lire. |
replaySafe | booléen | Rejouer le nœud après une tentative interrompue est-il sans danger ? Défaut : true pour none, false sinon. Déclarer true sur un nœud à effet engage : ses écritures sont dédupliquées par le produit. Sinon, une tentative interrompue est conclue comme incertaine et laissée à la décision du membre. |
retry | { maxAttempts, delayMs? } | Les défauts de nouvelles tentatives du type. Absents : ceux du moteur. |
timeoutMs | nombre | Le délai par défaut d’une tentative, en millisecondes. Absent : celui du moteur. |
Les nœuds IA partagent une même politique (AI_NODE_POLICY dans packages/nodes/src/catalog/ai-common.ts), exemple de retry et de timeoutMs. Marquer déclare { effect: 'external_write', replaySafe: true } : son écriture passe par les opérations sortantes du produit, indexées sur l’étape.
Un nœud dont l’effet n’est pas none doit transmettre context.idempotencyKey à chaque appel externe. Cette clé, dérivée de l’étape, rend inoffensif un rejeu après un crash.
requires : le mail déclencheur
requires: 'email' déclare que le nœud agit sur le mail déclencheur (Classer, Marquer). La validation du workflow refuse alors de publier un graphe dont un déclencheur lance des exécutions sans mail, avant même la première exécution. La vérification dans execute reste un filet pour les cas que la validation ne peut pas trancher (un workflow appelé par un autre).
La fonction execute
execute(context) est une fonction async. Elle doit toujours rendre une promesse, même sur des entrées hostiles : le test du catalogue appelle chaque nœud sans aucun service branché et avec des paramètres absurdes, et échoue si execute lève une exception de façon synchrone.
Le contexte (NodeExecutionContext) contient :
| Champ | Contenu |
|---|---|
email | Le mail déclencheur (CanonicalMessage), en lecture seule, ou undefined pour une exécution lancée sans mail (planification, webhook). |
data | Les données accumulées par les étapes précédentes. |
params | Les paramètres résolus. |
services | Les services injectés : context.services.get(JETON) rend le service ou lève une erreur s’il n’est pas branché ; has(JETON) le teste. |
idempotencyKey | La clé de l’étape, à transmettre à tout effet externe. |
abortSignal | Délai et annulation. Tout appel réseau doit le respecter. |
logger | Un journal minimal (debug, info, warn, error). Ne journalisez jamais le contenu d’un mail ni un secret. |
execution | Facultatif : { id, workflowId } de l’exécution courante, rien de plus. |
locale | Facultatif : 'fr' ou 'en', la langue du membre, pour les textes qu’un humain lit (résumés d’étape). |
simulated | true pendant un essai. |
Un nœud n’importe jamais un connecteur, le SDK d’un fournisseur ni la base de données. Il n’utilise que les services déclarés dans packages/nodes/src/services.ts et obtenus par le contexte : mail (MAIL_SERVICE), HTTP (HTTP_SERVICE), pièces jointes, LLM (LLM_SERVICE), profils, tables, intégrations, services Google et Microsoft, et les services de flux.
execute rend un NodeResult :
| Champ | Sens |
|---|---|
output | Le port de sortie emprunté, ou null pour terminer la branche sans erreur. |
data | Le patch que publie l’étape. Les nœuds suivants le lisent en {{ data.<step>.champ }} (voir Données et expressions). |
suspend | Facultatif : l’étape se suspend au lieu de conclure (approbation, attente, workflow appelé, boucle). Le nœud ne bloque jamais ; il rend une description (kind, title, timeoutMs, outcomes, onTimeout) et le moteur persiste l’attente. Seules ces quatre natures existent. |
Erreurs
Les nœuds lèvent les erreurs de packages/nodes/src/errors.ts. La seule question qui compte pour le moteur : une nouvelle tentative peut-elle aider ?
PermanentNodeError: réessayer ne changera rien (paramètre invalide, mail absent, 4xx métier). L’étape échoue.RetryableNodeError: transitoire (réseau, 5xx, annulation). Le moteur peut faire une nouvelle tentative de l’étape.
Toutes deux prennent un code stable (minuscules, de la forme famille_probleme), un message technique en anglais, et les options cause et details. details ne porte que des métadonnées, jamais le contenu d’un mail. Les codes courants sont dans NODE_ERROR_CODES : node_missing_email, node_invalid_param, node_nothing_to_do, node_aborted, node_service_failed. Pour relayer l’échec d’un appel de service, relayCallFailure garde la classification déjà faite par le service, au lieu de transformer un refus définitif en nouvelle tentative.
Appelez throwIfAborted(context.abortSignal, TYPE) avant et après chaque attente d’un service : un nœud qui ignore l’annulation fait vivre une étape au-delà de son délai.
La façon dont les étapes en échec apparaissent aux membres (sortie Erreur, nouvelles tentatives, workflow d’erreur) est décrite dans Erreurs et nouvelles tentatives.
Essais
Pendant un essai, context.simulated vaut true et les nœuds à effet doivent décrire ce qu’ils feraient au lieu de le faire. Le plus souvent, le nœud n’a rien à écrire : le moteur injecte des services qui simulent les écritures. Le service de mail, par exemple, n’enregistre rien chez le fournisseur et rend simulated: true ; le service d’intégration fait les lectures réelles et décrit les écritures. Marquer se contente de recopier dans ses données l’indicateur simulated rendu par le service.
Un nœud dont l’effet ne passe pas par un tel service teste lui-même context.simulated. C’est le cas des nœuds d’approbation, d’attente et de signal.
Si les données publiées pendant un essai diffèrent de celles d’une exécution réelle, documentez les deux formes dans la source de documentation du nœud.
Résumés d’étape et i18n
Libellés, descriptions et options du nœud s’écrivent dans la définition, en français et en anglais. Tout autre texte qu’un humain lit pendant l’exécution, comme un résumé d’étape, passe par les dictionnaires des nœuds packages/nodes/src/i18n/fr.ts et en.ts, jamais par une phrase construite dans le code.
Utilisez summaryPatch(localeOf(context), 'clé', paramètres) du module i18n des nœuds : il ajoute aux données summary (la phrase rendue), summaryKey et summaryParams, pour que l’éditeur puisse rendre le résumé plus tard dans une autre langue. Syntaxe des messages : {nom} pour interpoler, {compteur|singulier|pluriel} pour accorder selon la règle de pluriel de chaque langue. Une clé correspond à une phrase entière.
Les prompts envoyés à un modèle de langage s’écrivent en anglais dans le code ; la description du paramètre indique aux membres qu’ils peuvent réécrire un prompt dans leur langue.
Deux tests y veillent : packages/nodes/src/i18n/hardcoded.test.ts échoue sur du français en dur dans le catalogue, et packages/nodes/src/i18n/i18n.test.ts vérifie que les deux dictionnaires ont exactement les mêmes clés. Si votre nœud introduit un nouveau code d’erreur, donnez-lui un message sous errors dans packages/ui/src/i18n/locales/fr.json et en.json : c’est là que l’interface traduit les codes d’erreur.
Enregistrer le nœud au catalogue
Le catalogue est une liste statique dans packages/nodes/src/catalog/index.ts :
- importez la définition et ajoutez-la à
CATALOG_NODES, à la place de sa famille ; - ajoutez-la au bloc de réexports en fin de fichier, avec son type de paramètres ;
- mettez à jour
packages/nodes/src/catalog/catalog.test.ts, qui liste dans l’ordre toutes les clés de nœuds et l’effet de chaque type : un nouveau nœud fait échouer ce test tant que vous ne l’y avez pas ajouté, et c’est voulu.
createCatalogRegistry() construit le registre à partir de CATALOG_NODES ; le serveur, l’éditeur et la documentation lisent tous la même liste.
Tester le nœud
Chaque nœud a son fichier de test à côté de lui (mail-flag.test.ts). Les fonctions de packages/nodes/src/testing/context.ts construisent un contexte complet :
runNode(definition, { params, services, email, simulated, locale, abortSignal, … })applique les défauts du nœud comme le moteur, puis appelleexecute;bind(JETON, doublure)branche un service ; des doublures existent pour les services de mail, HTTP, pièces jointes, LLM et profils (createMailService,createHttpService,createAttachmentService,createLlmService,createProfileService) ;emailFixture()est le mail déclencheur fourni par défaut ; passezemail: undefinedpour tester une exécution sans mail ;createSignal()donne un signal annulable ; la clé d’idempotence par défaut eststep-42.
Couvrez au moins la déclaration (clé, effet, défauts qui passent la validation) et le comportement : données produites, clé d’idempotence transmise au service, erreurs définitives et transitoires, annulation. Les commandes sont dans Tests.
ts
it('n’envoie que les états renseignés', async () => {
const mail = createMailService();
const result = runNode(mailFlagNode, {
params: { seen: true },
services: [bind(MAIL_SERVICE, mail)],
});
await expect(result).resolves.toEqual({
output: 'main',
data: { opId: 'op-1', simulated: false, seen: true },
});
expect(mail.flags).toEqual([{ input: { seen: true }, idempotencyKey: 'step-42' }]);
});Source de documentation
Chaque nœud du catalogue doit avoir une source de documentation en deux fichiers : apps/docs/content/nodes/<type>.en.md et apps/docs/content/nodes/<type>.fr.md. Le type garde ses points : mail.flag.en.md, attachment.extract_text.fr.md. Le générateur écrit tout le reste à partir de la définition : noms, description, tableau des paramètres, ports, effet, connexion exigée.
La source commence par un frontmatter YAML au schéma fermé, suivi de Markdown libre :
markdown
---
ports: # facultatif : le sens de chaque port de SORTIE (clé = nom exact du port)
main: "Emprunté quand …"
data: # obligatoire, `data: []` si l’étape ne publie rien
- expression: "{{ data.<step>.opId }}"
type: string
description: "Ce que vaut cette donnée."
---
Introduction : à quoi sert le nœud, quand le préférer à un voisin.
## Exemple
Un exemple réaliste, avec les noms exacts des paramètres et les données produites.
## Conseils
Facultatif : pièges réels, limites, codes d’erreur.Le générateur échoue, et le build de la documentation avec lui, quand :
- un nœud n’a pas de source dans l’une des deux langues ;
meta.descriptionest vide dans une langue ;- le frontmatter contient une clé inconnue ou une entrée mal formée ;
- une clé de
portsn’est pas un port de sortie du nœud (sauf si ses sorties sont calculées à partir des paramètres) ; - une expression de
datane contient pas de{{ }}ou ne se lit pas (<step>est accepté comme gabarit) ; - un fournisseur déclare des données (il doit avoir
data: []) ; - une page contient le nom de code du produit ou une marque de travail en cours écrite en capitales.
Le générateur tourne avant chaque build et chaque serveur de développement de la documentation. Il lit les paquets compilés : construisez-les d’abord.
sh
pnpm build # ou au moins les paquets que lit la doc
pnpm --filter @manko/docs generate # génère seulement les pages
pnpm --filter @manko/docs dev # génère, puis sert le site en local
pnpm test --project docs # tests du générateurListe de vérification
[ ] packages/nodes/src/catalog/<nom>.ts defineNode : type, version 1, meta, params, ports, policy
[ ] meta : nom et description fr + en, icône, groupe, catégorie, ≥ 4 alias par langue
[ ] policy.effect honnête ; idempotencyKey transmise à tout effet externe
[ ] throwIfAborted autour de chaque attente de service ; erreurs classées définitives / transitoires
[ ] essais : écritures par un service qui simule, ou context.simulated testé
[ ] résumés d’étape par summaryPatch, clés dans packages/nodes/src/i18n/fr.ts ET en.ts
[ ] nouveaux codes d’erreur sous "errors" dans packages/ui/src/i18n/locales/fr.json ET en.json
[ ] packages/nodes/src/catalog/index.ts : CATALOG_NODES + réexport
[ ] packages/nodes/src/catalog/catalog.test.ts : clé et effet ajoutés
[ ] packages/nodes/src/catalog/<nom>.test.ts
[ ] apps/docs/content/nodes/<type>.en.md ET <type>.fr.md
[ ] pnpm typecheck, pnpm lint, pnpm test --project nodes, pnpm --filter @manko/docs generate