diff --git a/.flow/registry/plugins.json b/.flow/registry/plugins.json new file mode 100644 index 0000000..ef1d12a --- /dev/null +++ b/.flow/registry/plugins.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "generated_at": "2026-06-12T17:08:48.501401Z", + "registry_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "plugins": [] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c8075d..03d7c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,4 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo test --all + - run: cargo run -- registry scan --check diff --git a/.gitignore b/.gitignore index 644ab9d..1d46988 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target/ -/.flow/ +/.flow/* +!/.flow/registry/ +!/.flow/registry/plugins.json /logs/ /.repolens/ /flowrun-codex-ready/ diff --git a/Cargo.lock b/Cargo.lock index b2053e2..7757b71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -294,6 +303,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cron" version = "0.15.0" @@ -305,12 +323,32 @@ dependencies = [ "winnow", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -491,6 +529,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1388,6 +1436,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "thiserror", "tokio", "tracing", @@ -1613,6 +1662,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1927,6 +1987,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index caf8a82..b42f29c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } tracing = "0.1" diff --git a/README.md b/README.md index 2e9b504..c78ff82 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ logs/ //step.metadata.json ``` -The `.flow/` directory is ignored by Git. +The `.flow/` directory is ignored by Git except `.flow/registry/plugins.json`, which is generated and versioned as the local plugin contract. ## CLI Commands @@ -332,6 +332,37 @@ flow plugin test "unused-command" .\plugin-output.json flow plugin test "powershell -NoProfile -File .\plugin.ps1" .\plugin-input.json ``` +### Registry + +The registry is the generated local plugin contract shared with validation, UI, MCP and `runflow-agent`. + +```powershell +flow registry scan +flow registry scan --check +flow registry list plugins +flow registry inspect +flow registry export --for-agent +``` + +V1 scans `plugins/*/plugin.yml` and writes `.flow/registry/plugins.json`. The file is generated, committed, and checked in CI with `flow registry scan --check`. Empty registries are valid and contain `plugins: []`. + +Plugin workflows keep the explicit syntax: + +```yaml +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/my_plugin/run.py"] + plugin_id: my_plugin + input: {} +``` + +`flow validate` rejects plugin steps whose `plugin_id` is registered but whose command does not reference the registered `entrypoint`. + +See `docs/registry.md` for the development workflow and contract. + ### Packages Build a package from a workflow: diff --git a/docs/AGENT_MVP_SPEC.md b/docs/AGENT_MVP_SPEC.md new file mode 100644 index 0000000..b95b30e --- /dev/null +++ b/docs/AGENT_MVP_SPEC.md @@ -0,0 +1,450 @@ +# RunFlow Agent MVP Spec + +Status: external project draft +Target repository: `runflow-agent` +RunFlow core status: frozen; no LLM or Agent CLI lives in this repository. +Scope: Agent v1 assist-only, implemented outside `runflow` + +## Decision + +RunFlow Agent v1 is a separate assistant project, not an operator built into RunFlow core. + +It can read RunFlow state, generate drafts, review workflows, explain runs, and produce reports. It must not execute, cancel, schedule, notify, edit secrets, call external APIs, or mutate project state without a future explicit feature. + +## Goals + +- Help users create valid RunFlow workflows from plain language. +- Review an existing workflow before registration or execution. +- Explain why a run failed using events, manifests, step metadata, stdout, and stderr. +- Produce local daily reports from structured RunFlow data. +- Keep all agent output deterministic enough to validate before showing it. +- Keep RunFlow usable without any model installed and without any LLM dependency. + +## Non-goals + +- No autopilot. +- No automatic remediation. +- No direct shell execution by the agent. +- No external API calls by default. +- No secret management. +- No automatic `job add`, `job run`, `run cancel`, webhook, email, or notification. +- No fine-tuning work in v1. +- No MCP server dependency for the external agent. + +## Safety Model + +The model never controls RunFlow directly. + +```text +RunFlow data + -> Agent context builder + -> Model provider + -> Strict structured output + -> JSON schema validation + -> RunFlow workflow schema validation when YAML is generated + -> Policy check + -> User-facing result +``` + +If any step fails, the command returns a clear error and no state is changed. + +## Modes + +Only one mode is implemented in v1: + +```toml +[agent] +enabled = false +mode = "assist" +provider = "ollama" +model = "qwen2.5-coder:1.5b" +timeout_seconds = 30 +``` + +Future modes such as `watch`, `oncall`, and `autopilot` stay out of v1. + +## Default Permissions + +All action permissions are disabled in v1. + +```toml +[agent.permissions] +create_workflows = false +edit_workflows = false +run_workflows = false +cancel_runs = false +rerun_failed = false +send_reports = false +send_alerts = false +call_external_api = false +manage_secrets = false +execute_shell = false +``` + +The agent may propose a command, but it must not run it. + +## CLI Scope + +The CLI belongs to the future `runflow-agent` repository. Do not add this command group to `runflow` core. + +```powershell +runflow-agent +``` + +### `runflow-agent draft` + +Generate a workflow draft from a user request. + +```powershell +runflow-agent draft --prompt "Ping 1.1.1.1 every 5 minutes" +runflow-agent draft --input .\request.txt +runflow-agent draft --prompt "Backup logs" --output .\workflow.yml +``` + +Behavior: + +- returns YAML only by default; +- validates generated YAML with the RunFlow workflow schema; +- rejects unknown fields; +- does not register the job; +- does not run the job; +- writes the file only when `--output` is provided. + +Accepted workflow fields must match `src/schema_defs/v1/workflow.schema.json`. + +### `runflow-agent review` + +Review an existing workflow file. + +```powershell +runflow-agent review .\workflow.yml +runflow-agent review .\workflow.yml --format json +``` + +Behavior: + +- validates schema first; +- reports invalid YAML or schema diagnostics; +- detects risky patterns such as shell redirection, unbounded output, missing timeout, broad env usage, or suspicious commands; +- suggests changes without editing the file. + +### `runflow-agent explain-run` + +Explain a run from structured RunFlow data. + +```powershell +runflow-agent explain-run +runflow-agent explain-run --format json +``` + +Inputs: + +- `.flow/runs//events.jsonl`; +- `.flow/runs//manifest.json`; +- `logs//workflow.metadata.json`; +- `logs///step.metadata.json`; +- `logs///stdout.log`; +- `logs///stderr.log`. + +Behavior: + +- summarizes the failed step; +- includes relevant exit code and stderr excerpts; +- explains likely cause; +- suggests manual next steps; +- never retries or cancels anything. + +### `runflow-agent report daily` + +Generate a local daily report. + +```powershell +runflow-agent report daily +runflow-agent report daily --from 2026-06-09T00:00:00Z --to 2026-06-10T00:00:00Z +runflow-agent report daily --format json +``` + +Inputs: + +- event store; +- manifests; +- SQLite projections when available. + +Behavior: + +- counts runs, successes, failures, cancellations; +- lists unstable jobs; +- lists open or recent incidents; +- suggests manual improvements; +- does not send the report anywhere. + +## Output Contracts + +Agent output must be schema validated before display. + +Recommended schema files: + +```text +schemas/agent-draft.schema.json +schemas/agent-review.schema.json +schemas/agent-explain-run.schema.json +schemas/agent-report.schema.json +``` + +### Draft Response + +```json +{ + "kind": "draft_workflow", + "workflow_yaml": "name: ping-monitor\nsteps:\n - name: ping\n type: command\n run:\n command: ping\n args: [\"-n\", \"4\", \"1.1.1.1\"]\n", + "validation": { + "valid": true, + "errors": [] + }, + "warnings": [] +} +``` + +### Review Response + +```json +{ + "kind": "workflow_review", + "valid": true, + "findings": [ + { + "severity": "warning", + "path": "/steps/0/run", + "message": "Command has no timeout.", + "suggestion": "Add timeout: 30s." + } + ] +} +``` + +### Run Explanation Response + +```json +{ + "kind": "run_explanation", + "run_id": "", + "status": "FAILED", + "summary": "The ping step failed because the command exited with code 1.", + "failed_step": "ping", + "evidence": [ + "exit_code=1", + "stderr contains timeout" + ], + "suggested_next_steps": [ + "Check network connectivity.", + "Increase timeout if failures are intermittent." + ] +} +``` + +### Daily Report Response + +```json +{ + "kind": "daily_report", + "period": { + "from": "2026-06-09T00:00:00Z", + "to": "2026-06-10T00:00:00Z" + }, + "runs": { + "total": 0, + "success": 0, + "failed": 0, + "cancelled": 0 + }, + "unstable_jobs": [], + "incidents": [], + "recommendations": [] +} +``` + +## Workflow Generation Rules + +Generated workflows must follow current RunFlow schema. + +Allowed top-level fields in v1: + +- `name` +- `version` +- `schema_version` +- `schedule` +- `failure_policy` +- `concurrency` +- `limits` +- `locks` +- `secrets` +- `notifications` +- `retention` +- `steps` +- `tests` + +Allowed step types: + +- `command` +- `plugin` +- `sleep` +- `wait_until` + +Rules: + +- prefer structured command form: `run.command` + `run.args`; +- avoid shell strings unless the user explicitly asks for shell behavior; +- do not invent fields outside the schema; +- do not inline secrets; +- use `secrets..from_env` for secret references; +- use `schedule: false` or omit `schedule` for manual workflows; +- use detailed `schedule.cron`, `schedule.timezone`, `schedule.enabled` for scheduled workflows; +- include timeouts for command steps when possible. + +## Context Builder + +The context builder must keep prompts small and factual. + +For workflow draft: + +- user request; +- supported schema summary; +- examples from current schema only. + +For workflow review: + +- workflow YAML; +- schema diagnostics; +- extracted commands; +- schedule and concurrency settings. + +For run explanation: + +- run state; +- event timeline; +- failed steps; +- exit codes; +- bounded stdout/stderr excerpts; +- manifest summary. + +For daily report: + +- aggregate counts; +- unstable jobs; +- recent failures; +- cancellation count; +- slowest runs if available. + +Never send full unbounded logs to the model. + +## Policy Checks + +The v1 policy engine is mostly a deny-by-default validator. + +It must reject: + +- requests to execute shell commands; +- requests to run, cancel, or rerun jobs; +- requests to send webhooks, emails, Slack messages, or external API calls; +- generated workflows with invalid schema; +- generated workflows containing raw secret values; +- generated workflows with unknown fields. + +It may allow: + +- local file output when the user passes `--output`; +- text and JSON summaries; +- workflow YAML drafts. + +## Audit Trail + +V1 should record agent command usage, but not as run events tied to fake runs. + +Recommended local file: + +```text +.flow/agent/audit.jsonl +``` + +Each line: + +```json +{ + "timestamp": "2026-06-09T12:00:00Z", + "command": "agent review", + "model": "qwen2.5-coder:1.5b", + "status": "success", + "changed_files": [], + "warnings": [] +} +``` + +If later RunFlow gets global events, agent audit can move there. + +## Internal Rust Structure + +Suggested files in `runflow-agent`: + +```text +src/cli.rs +src/config.rs +src/context.rs +src/model.rs +src/output.rs +src/policy.rs +src/report.rs +src/review.rs +src/draft.rs +src/audit.rs +``` + +Core types stay outside text CLI parsing. Agent code must call Rust modules directly. + +## Model Provider + +V1 provider: + +- Ollama HTTP API on localhost. + +Required behavior: + +- timeout; +- clear error when Ollama is unavailable; +- no panic; +- deterministic settings where possible; +- low temperature for YAML/JSON generation. + +RunFlow core must still build and run without Ollama installed because Ollama belongs to `runflow-agent`. + +## Acceptance Criteria + +V1 is done when: + +- `runflow-agent draft` generates schema-valid workflow YAML for simple requests; +- `runflow-agent review` reports schema errors and risk findings without editing files; +- `runflow-agent explain-run` explains a failed run from local logs/events; +- `runflow-agent report daily` produces text and JSON summaries; +- all model outputs are JSON-schema validated; +- generated workflows are validated by the existing workflow schema; +- no agent command runs jobs, cancels runs, sends alerts, or calls external APIs; +- errors are returned cleanly; +- tests cover success, invalid model output, invalid workflow YAML, missing run, and Ollama unavailable. + +## Implementation Plan + +1. Create the separate `runflow-agent` project with CLI and config parsing. +2. Add model provider trait with Ollama implementation. +3. Add output schemas and validation. +4. Add workflow draft command. +5. Add workflow review command. +6. Add run explanation command. +7. Add daily report command. +8. Add audit JSONL. +9. Add `runflow-agent` README sections after the feature is implemented. + +## Open Questions + +- Should `runflow-agent draft --output` overwrite by default or require `--force`? +- Should Ollama be the only v1 provider, or should a mock provider be available for tests and offline demos? +- Should report generation use SQLite projections first, then fallback to event replay? +- Should agent audit become a first-class global event type later? diff --git a/docs/DOCS_PLAN.md b/docs/DOCS_PLAN.md index ce715c9..7a87a31 100644 --- a/docs/DOCS_PLAN.md +++ b/docs/DOCS_PLAN.md @@ -20,6 +20,7 @@ Ce fichier est une checklist de couverture pour la doc GitHub Pages (`docs/index 12. Troubleshooting: common errors + where to look (`logs/`, `.flow/`) 13. Release: tagging + artifacts 14. Development: fmt/clippy/test/build +15. External agent integration notes only; no Agent CLI is part of RunFlow core. Canonical source of truth for the CLI list: `src/cli.rs` enums (`Command`, `JobCommand`, `RunCommand`, etc.). @@ -41,5 +42,6 @@ Canonical source of truth for the CLI list: `src/cli.rs` enums (`Command`, `JobC 12. Dépannage: erreurs typiques + où regarder (`logs/`, `.flow/`) 13. Release: tags + artefacts 14. Dev: fmt/clippy/test/build +15. Notes d'integration agent externe seulement; aucune CLI Agent dans le coeur RunFlow. Source de vérité pour la liste de commandes: `src/cli.rs`. diff --git a/docs/registry.md b/docs/registry.md new file mode 100644 index 0000000..f5b7262 --- /dev/null +++ b/docs/registry.md @@ -0,0 +1,274 @@ +# RunFlow Registry v1 + +Le registry fournit une source de verite locale pour les plugins disponibles dans un projet RunFlow. + +V1 indexe uniquement les plugins locaux declares dans : + +```txt +plugins/*/plugin.yml +``` + +Le fichier genere est : + +```txt +.flow/registry/plugins.json +``` + +Ce fichier est genere par RunFlow. Il ne doit pas etre edite a la main. + +Decision v1 : `.flow/registry/plugins.json` est versionne dans les projets RunFlow. + +Pourquoi : + +- review plus lisible ; +- audit plus fiable ; +- contexte agent reproductible ; +- `registry_hash` exploitable en CI. + +## Mode Operatoire Dev + +Quand un plugin local est ajoute, modifie ou supprime : + +1. Modifier uniquement les manifests source `plugins/*/plugin.yml`. +2. Ne jamais editer `.flow/registry/plugins.json` a la main. +3. Lancer : + +```powershell +flow registry scan +``` + +4. Verifier le diff de `.flow/registry/plugins.json`. +5. Lancer : + +```powershell +flow registry scan --check +flow validate .\workflow.yml +``` + +6. Committer ensemble : + +```txt +plugins//plugin.yml +.flow/registry/plugins.json +workflows utilisant le plugin, si applicable +``` + +En CI, utiliser : + +```powershell +flow registry scan --check +``` + +Cette commande doit echouer si le registry versionne n'est plus aligne avec les manifests locaux. + +Pour ce repo, le registry peut rester vide tant qu'aucun plugin officiel n'est fourni. + +## Manifest Plugin + +Exemple : + +```yaml +name: ssl_check +version: 0.1.0 +description: Check SSL/TLS certificate expiration. +runtime: python +entrypoint: check_ssl.py + +inputs: + host: + type: string + required: true + port: + type: integer + default: 443 + +outputs: + status: + type: string + enum: [ok, warning, expired, error] + +permissions: + network: + required: true + allow_ports: [443] +``` + +Regles v1 : + +- `name`, `version`, `description`, `runtime`, `entrypoint` sont obligatoires. +- `version` doit etre au format `major.minor.patch`. +- `runtime` accepte `python`, `node`, `shell`, `binary`. +- `entrypoint` doit exister dans le dossier du plugin. +- les noms de plugins doivent etre uniques. +- les permissions sont declaratives uniquement, non appliquees en v1. + +## Contrat A Respecter + +### Source De Verite + +- La source de verite est toujours `plugins/*/plugin.yml`. +- `.flow/registry/plugins.json` est un artefact genere et versionne. +- Un consumer ne doit pas inventer de plugin absent de `plugins.json`. +- Un consumer ne doit pas inventer de chemin de script absent du registry. + +### Contrat `plugins.json` + +Chaque plugin contient : + +```txt +name +version +description +runtime +entrypoint +manifest_path +plugin_dir +inputs +outputs +permissions +``` + +Contraintes : + +- `entrypoint`, `manifest_path` et `plugin_dir` sont relatifs a la racine projet. +- les chemins utilisent `/`, meme sous Windows. +- `plugins` est trie par `name`. +- `registry_hash` ne depend que du contenu canonique de `plugins`. +- `generated_at` est informatif et ne doit pas servir a detecter un changement de contrat. + +### Contrat Workflow + +Un workflow qui utilise un plugin doit garder la syntaxe explicite : + +```yaml +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/my_plugin/run.py"] + plugin_id: my_plugin + input: {} +``` + +Contraintes : + +- `type: plugin` est obligatoire. +- `plugin_id` doit exister dans `.flow/registry/plugins.json`. +- `run.command` ou `run.args` doit referencer l'`entrypoint` declare dans le registry. +- `input` doit etre un objet. +- les inputs requis doivent etre presents sauf si le manifest declare un `default`. +- les inputs non declares sont invalides. +- les types acceptes sont `string`, `integer`, `number`, `boolean`, `object`, `array`. + +### Contrat Permissions + +- Les permissions sont seulement declaratives en v1. +- `flow validate` ne bloque pas un workflow sur les permissions. +- L'UI, MCP et `runflow-agent` peuvent les afficher comme information. +- Aucun sandbox reel n'est implique par le registry v1. + +### Contrat Agent/UI/MCP + +Les consumers doivent utiliser : + +```powershell +flow registry export --for-agent +``` + +Regles : + +- utiliser uniquement les plugins listes ; +- ne pas inventer de tools ; +- retourner `needs_tool` si aucun plugin ne correspond ; +- ne pas generer de commande shell directe quand un plugin adapte existe. + +## Commandes + +Scanner les plugins : + +```powershell +flow registry scan +flow registry scan --check +``` + +`--check` rescane les plugins, recalcule le registry attendu et echoue si `.flow/registry/plugins.json` n'est pas a jour. `generated_at` est ignore dans cette comparaison. + +Lister : + +```powershell +flow registry list plugins +flow registry list plugins --format json +``` + +Inspecter : + +```powershell +flow registry inspect ssl_check +flow registry inspect ssl_check --format json +``` + +Exporter un contexte compact pour `runflow-agent` : + +```powershell +flow registry export --for-agent +``` + +## Workflow + +La syntaxe workflow reste explicite : + +```yaml +name: ssl-monitor +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/ssl_check/check_ssl.py"] + plugin_id: ssl_check + input: + host: api.example.com + port: 443 +``` + +`flow validate` utilise le registry si le workflow contient des steps `type: plugin`. + +Verifications v1 : + +- plugin inconnu ; +- registry manquant ; +- entrypoint detourne ou absent ; +- input requis absent ; +- type d'input incorrect ; +- input inconnu. + +## Hash + +`registry_hash` est calcule uniquement a partir du champ `plugins`, trie par `name`. + +Il exclut : + +- `generated_at` ; +- `registry_hash`. + +Ainsi, deux scans sans changement de plugin gardent le meme hash. + +Canonicalisation : + +- JSON compact, sans indentation ; +- cles d'objets triees ; +- plugins tries par `name` ; +- chemins normalises avec `/` ; +- aucune dependance au separateur Windows/Linux. + +Si aucun plugin n'existe, `flow registry scan` genere un registry vide avec `plugins: []`. Ce n'est pas une erreur. + +## Limites V1 + +- pas de registry remote ; +- pas de marketplace ; +- pas de sandbox reel ; +- pas de policies globales ; +- pas de capabilities globales ; +- permissions affichees uniquement, jamais appliquees. diff --git a/schemas/plugin.schema.json b/schemas/plugin.schema.json new file mode 100644 index 0000000..cc2a7c4 --- /dev/null +++ b/schemas/plugin.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowrun.dev/schemas/v1/plugin.schema.json", + "title": "RunFlow Registry Plugin Manifest", + "type": "object", + "required": ["name", "version", "description", "runtime", "entrypoint"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$" }, + "version": { "type": "string" }, + "description": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "enum": ["python", "node", "shell", "binary"] }, + "entrypoint": { "type": "string", "minLength": 1 }, + "inputs": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/field" } + }, + "outputs": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/field" } + }, + "permissions": { + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "field": { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["string", "integer", "number", "boolean", "object", "array"] }, + "required": { "type": "boolean", "default": false }, + "default": true, + "enum": { "type": "array" } + } + } + } +} diff --git a/src/cli.rs b/src/cli.rs index 68fdbba..bdaed9e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,6 +20,7 @@ use crate::packages::{ build_package_from_workflow_file, read_package_or_legacy_workflow, write_package, }; use crate::plugins::{PluginInput, PluginManifest, PluginRuntime, parse_manifest}; +use crate::registry::{self, RegistryDocument}; use crate::retention::{RetentionPolicy, run_retention}; use crate::scheduler::Scheduler; use crate::schemas; @@ -78,6 +79,10 @@ enum Command { #[command(subcommand)] command: RetentionCommand, }, + Registry { + #[command(subcommand)] + command: RegistryCommand, + }, Schedule { #[command(subcommand)] command: ScheduleCommand, @@ -197,6 +202,41 @@ enum RetentionCommand { }, } +#[derive(Debug, Subcommand)] +enum RegistryCommand { + Scan { + #[arg(long)] + check: bool, + }, + List { + #[command(subcommand)] + command: RegistryListCommand, + }, + Inspect { + plugin_name: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + Export { + #[arg(long)] + for_agent: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum RegistryListCommand { + Plugins { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + Text, + Json, +} + #[derive(Debug, Subcommand)] enum ScheduleCommand { Next { @@ -225,14 +265,15 @@ impl Cli { Command::Plugin { command } => run_plugin_command(&root, command).await, Command::Package { command } => run_package_command(&root, command), Command::Test { job_name, verbose } => run_test_command(&root, &job_name, verbose), - Command::Validate { workflow } => validate_workflow(&workflow), + Command::Validate { workflow } => validate_workflow(&root, &workflow), Command::Migrate { workflow } => { - validate_workflow(&workflow)?; + validate_workflow(&root, &workflow)?; println!("migration not required: {}", workflow.display()); Ok(()) } Command::Daemon { command } => run_daemon_command(&root, command).await, Command::Retention { command } => run_retention_command(&root, command), + Command::Registry { command } => run_registry_command(&root, command), Command::Schedule { command } => run_schedule_command(command), Command::Version => { println!("runflow {}", env!("CARGO_PKG_VERSION")); @@ -1156,6 +1197,121 @@ fn run_retention_command(root: &Path, command: RetentionCommand) -> Result<()> { } } +fn run_registry_command(root: &Path, command: RegistryCommand) -> Result<()> { + match command { + RegistryCommand::Scan { check } => { + let summary = if check { + registry::scan_check(root)? + } else { + registry::scan_and_write(root)? + }; + println!("Scanned plugins: {}", summary.scanned_plugins); + println!("Valid plugins: {}", summary.valid_plugins); + println!("Invalid plugins: {}", summary.invalid_plugins); + if check { + println!("Registry up to date: {}", summary.registry_path.display()); + } else { + println!("Registry written: {}", summary.registry_path.display()); + } + Ok(()) + } + RegistryCommand::List { command } => match command { + RegistryListCommand::Plugins { format } => { + let registry = registry::read_registry(root)?; + match format { + OutputFormat::Text => print_registry_plugins(®istry), + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(®istry.plugins)?); + Ok(()) + } + } + } + }, + RegistryCommand::Inspect { + plugin_name, + format, + } => { + let registry = registry::read_registry(root)?; + let plugin = registry + .plugins + .iter() + .find(|plugin| plugin.name == plugin_name) + .with_context(|| format!("plugin not found in registry: {plugin_name}"))?; + match format { + OutputFormat::Text => print_registry_plugin(plugin), + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(plugin)?); + Ok(()) + } + } + } + RegistryCommand::Export { for_agent } => { + if !for_agent { + bail!("registry export currently requires --for-agent"); + } + let context = registry::export_for_agent(root)?; + println!("{}", serde_json::to_string_pretty(&context)?); + Ok(()) + } + } +} + +fn print_registry_plugins(registry: &RegistryDocument) -> Result<()> { + println!( + "{:<24} {:<10} {:<8} DESCRIPTION", + "NAME", "VERSION", "RUNTIME" + ); + for plugin in ®istry.plugins { + println!( + "{:<24} {:<10} {:<8} {}", + plugin.name, + plugin.version, + plugin.runtime.as_str(), + plugin.description + ); + } + Ok(()) +} + +fn print_registry_plugin(plugin: ®istry::RegistryPlugin) -> Result<()> { + println!("Plugin: {}", plugin.name); + println!("Version: {}", plugin.version); + println!("Runtime: {}", plugin.runtime.as_str()); + println!("Entrypoint: {}", plugin.entrypoint); + println!(); + println!("Inputs:"); + for (name, field) in &plugin.inputs { + let detail = if field.required { + "required".to_owned() + } else if let Some(default) = &field.default { + format!("default={default}") + } else { + "optional".to_owned() + }; + println!(" {:<18} {:<8} {}", name, field.field_type.as_str(), detail); + } + println!(); + println!("Outputs:"); + for (name, field) in &plugin.outputs { + if let Some(values) = &field.enum_values { + println!( + " {:<18} {:<8} enum={}", + name, + field.field_type.as_str(), + serde_json::to_string(values)? + ); + } else { + println!(" {:<18} {}", name, field.field_type.as_str()); + } + } + println!(); + println!("Permissions (declarative, not enforced in v1):"); + for (name, value) in &plugin.permissions { + println!(" {name} = {}", serde_json::to_string(value)?); + } + Ok(()) +} + fn run_schedule_command(command: ScheduleCommand) -> Result<()> { let (label, expression, count, from) = match command { ScheduleCommand::Next { @@ -1200,12 +1356,20 @@ fn run_schedule_command(command: ScheduleCommand) -> Result<()> { Ok(()) } -fn validate_workflow(workflow: &Path) -> Result<()> { +fn validate_workflow(root: &Path, workflow: &Path) -> Result<()> { let source = fs::read_to_string(workflow) .with_context(|| format!("failed to read workflow {}", workflow.display()))?; let diagnostics = schemas::validate_workflow_yaml(&source)?; if diagnostics.is_empty() { - WorkflowDefinition::from_yaml(&source)?; + let workflow_definition = WorkflowDefinition::from_yaml(&source)?; + let registry_diagnostics = registry::validate_workflow_plugins(root, &workflow_definition); + if !registry_diagnostics.is_empty() { + eprintln!( + "{}", + serde_json::to_string_pretty(®istry::invalid_payload(registry_diagnostics))? + ); + bail!("invalid workflow: {}", workflow.display()); + } println!("valid: {}", workflow.display()); Ok(()) } else { @@ -1337,6 +1501,11 @@ mod tests { vec!["flow", "migrate", "workflow.yml"], vec!["flow", "daemon", "status"], vec!["flow", "retention", "run"], + vec!["flow", "registry", "scan"], + vec!["flow", "registry", "scan", "--check"], + vec!["flow", "registry", "list", "plugins"], + vec!["flow", "registry", "inspect", "ssl_check"], + vec!["flow", "registry", "export", "--for-agent"], vec!["flow", "schedule", "next", "0 */5 * * * * *"], vec!["flow", "schedule", "workflow", "workflow.yml"], vec!["flow", "version"], diff --git a/src/dag.rs b/src/dag.rs index 71cfbca..7398ede 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail}; use petgraph::algo::toposort; use petgraph::graph::{DiGraph, NodeIndex}; use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] @@ -85,6 +86,7 @@ pub struct StepDefinition { pub depends_on: Vec, pub run: Option, pub plugin_id: Option, + pub input: Option, pub duration: Option, pub command: Option, pub interval: Option, diff --git a/src/engine.rs b/src/engine.rs index 765fdbb..1182fae 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -164,7 +164,7 @@ impl WorkflowEngine { step_name: Some(step.name.clone()), job_name: None, }, - input: json!({}), + input: step.input.clone().unwrap_or_else(|| json!({})), env: Default::default(), paths: PluginPaths { work_dir: workspace.work_dir.display().to_string(), @@ -239,6 +239,7 @@ mod tests { working_directory: None, })), plugin_id: None, + input: None, duration: None, command: None, interval: None, @@ -272,6 +273,7 @@ mod tests { working_directory: None, })), plugin_id: None, + input: None, duration: None, command: None, interval: None, @@ -304,6 +306,7 @@ mod tests { depends_on: vec![], run: None, plugin_id: None, + input: None, duration: Some("1ms".to_owned()), command: None, interval: None, diff --git a/src/lib.rs b/src/lib.rs index 6a31318..f5dd141 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod manifest; pub mod packages; pub mod plugins; pub mod projections; +pub mod registry; pub mod retention; pub mod scheduler; pub mod schemas; diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..3182933 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,883 @@ +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct RegistryDocument { + pub schema_version: u32, + pub generated_at: DateTime, + pub registry_hash: String, + pub plugins: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct RegistryPlugin { + pub name: String, + pub version: String, + pub description: String, + pub runtime: RegistryPluginRuntime, + pub entrypoint: String, + pub manifest_path: String, + pub plugin_dir: String, + #[serde(default)] + pub inputs: BTreeMap, + #[serde(default)] + pub outputs: BTreeMap, + #[serde(default)] + pub permissions: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegistryPluginRuntime { + Python, + Node, + Shell, + Binary, +} + +impl RegistryPluginRuntime { + pub fn as_str(self) -> &'static str { + match self { + Self::Python => "python", + Self::Node => "node", + Self::Shell => "shell", + Self::Binary => "binary", + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct RegistryField { + #[serde(rename = "type")] + pub field_type: RegistryFieldType, + #[serde(default)] + pub required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegistryFieldType { + String, + Integer, + Number, + Boolean, + Object, + Array, +} + +impl RegistryFieldType { + pub fn as_str(self) -> &'static str { + match self { + Self::String => "string", + Self::Integer => "integer", + Self::Number => "number", + Self::Boolean => "boolean", + Self::Object => "object", + Self::Array => "array", + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct RegistryScanSummary { + pub scanned_plugins: usize, + pub valid_plugins: usize, + pub invalid_plugins: usize, + pub registry_path: PathBuf, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct RegistryLintDiagnostic { + pub code: String, + pub message: String, + pub field: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +#[derive(Debug, Deserialize)] +struct RegistryPluginManifest { + name: String, + version: String, + description: String, + runtime: RegistryPluginRuntime, + entrypoint: String, + #[serde(default)] + inputs: BTreeMap, + #[serde(default)] + outputs: BTreeMap, + #[serde(default)] + permissions: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct AgentRegistryContext { + schema_version: u32, + registry_hash: String, + generated_at: DateTime, + plugins: Vec, + rules: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +struct AgentPlugin { + name: String, + description: String, + inputs: BTreeMap, + outputs: BTreeMap, + permissions: BTreeMap, +} + +pub fn scan_and_write(root: impl AsRef) -> Result { + let root = root.as_ref(); + let plugins = scan_plugins(root)?; + let summary = RegistryScanSummary { + scanned_plugins: plugins.len(), + valid_plugins: plugins.len(), + invalid_plugins: 0, + registry_path: registry_path(root), + }; + write_registry(root, plugins)?; + Ok(summary) +} + +pub fn scan_check(root: impl AsRef) -> Result { + let root = root.as_ref(); + let plugins = scan_plugins(root)?; + let expected_hash = registry_hash(&plugins)?; + let current = read_registry(root)?; + if current.schema_version != 1 + || current.registry_hash != expected_hash + || current.plugins != plugins + { + bail!( + "registry is out of date: run `flow registry scan` and commit {}", + registry_path(root).display() + ); + } + Ok(RegistryScanSummary { + scanned_plugins: plugins.len(), + valid_plugins: plugins.len(), + invalid_plugins: 0, + registry_path: registry_path(root), + }) +} + +pub fn scan_plugins(root: impl AsRef) -> Result> { + let root = root.as_ref(); + let mut plugins = Vec::new(); + let mut names = HashSet::new(); + + for manifest_path in manifest_paths(root)? { + let source = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let manifest = parse_manifest_yaml(&source) + .with_context(|| format!("invalid plugin manifest {}", manifest_path.display()))?; + validate_manifest(root, &manifest_path, &manifest)?; + if !names.insert(manifest.name.clone()) { + bail!("duplicate plugin name: {}", manifest.name); + } + plugins.push(to_registry_plugin(root, &manifest_path, manifest)?); + } + + plugins.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(plugins) +} + +pub fn write_registry( + root: impl AsRef, + plugins: Vec, +) -> Result { + let root = root.as_ref(); + let document = RegistryDocument { + schema_version: 1, + generated_at: Utc::now(), + registry_hash: registry_hash(&plugins)?, + plugins, + }; + let path = registry_path(root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write(&path, serde_json::to_vec_pretty(&document)?) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(document) +} + +pub fn read_registry(root: impl AsRef) -> Result { + let path = registry_path(root.as_ref()); + let source = fs::read_to_string(&path) + .with_context(|| format!("registry missing: {}", path.display()))?; + serde_json::from_str(&source).with_context(|| format!("failed to parse {}", path.display())) +} + +pub fn registry_path(root: impl AsRef) -> PathBuf { + root.as_ref() + .join(".flow") + .join("registry") + .join("plugins.json") +} + +pub fn export_for_agent(root: impl AsRef) -> Result { + let registry = read_registry(root)?; + Ok(AgentRegistryContext { + schema_version: registry.schema_version, + registry_hash: registry.registry_hash, + generated_at: registry.generated_at, + plugins: registry + .plugins + .into_iter() + .map(|plugin| AgentPlugin { + name: plugin.name, + description: plugin.description, + inputs: field_type_map(plugin.inputs), + outputs: field_type_map(plugin.outputs), + permissions: agent_permissions(plugin.permissions), + }) + .collect(), + rules: vec![ + "Use only registered plugins.", + "Do not invent tools.", + "Return needs_tool if no plugin matches.", + "Do not execute shell directly.", + ], + }) +} + +pub fn validate_workflow_plugins( + root: impl AsRef, + workflow: &crate::dag::WorkflowDefinition, +) -> Vec { + let plugin_steps = workflow + .steps + .iter() + .enumerate() + .filter(|(_, step)| step.step_type == crate::dag::StepType::Plugin) + .collect::>(); + if plugin_steps.is_empty() { + return Vec::new(); + } + + let registry = match read_registry(root) { + Ok(registry) => registry, + Err(error) => { + return vec![RegistryLintDiagnostic { + code: "registry_missing".to_owned(), + message: format!("{error:#}"), + field: "steps".to_owned(), + suggestion: Some( + "Run `flow registry scan` before validating plugin workflows.".to_owned(), + ), + }]; + } + }; + + let registered = registry + .plugins + .iter() + .map(|plugin| (plugin.name.as_str(), plugin)) + .collect::>(); + let suggestions = registry + .plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect::>() + .join(", "); + + let mut diagnostics = Vec::new(); + for (index, step) in plugin_steps { + let Some(plugin_id) = step.plugin_id.as_deref() else { + diagnostics.push(RegistryLintDiagnostic { + code: "unknown_plugin".to_owned(), + message: "Plugin step has no plugin_id.".to_owned(), + field: format!("steps[{index}].plugin_id"), + suggestion: if suggestions.is_empty() { + None + } else { + Some(format!("Use one of: {suggestions}")) + }, + }); + continue; + }; + let Some(plugin) = registered.get(plugin_id) else { + diagnostics.push(RegistryLintDiagnostic { + code: "unknown_plugin".to_owned(), + message: format!("Plugin {plugin_id} is not registered."), + field: format!("steps[{index}].plugin_id"), + suggestion: if suggestions.is_empty() { + None + } else { + Some(format!("Use one of: {suggestions}")) + }, + }); + continue; + }; + + validate_step_entrypoint(index, step, plugin, &mut diagnostics); + validate_step_input(index, step.input.as_ref(), plugin, &mut diagnostics); + } + + diagnostics +} + +fn parse_manifest_yaml(source: &str) -> Result { + serde_yaml::from_str(source).context("failed to parse plugin.yml") +} + +fn validate_manifest( + root: &Path, + manifest_path: &Path, + manifest: &RegistryPluginManifest, +) -> Result<()> { + if manifest.name.trim().is_empty() { + bail!("name is required"); + } + if manifest.description.trim().is_empty() { + bail!("description is required"); + } + if !is_semver(&manifest.version) { + bail!("version must be SemVer: {}", manifest.version); + } + for (name, field) in manifest.inputs.iter().chain(manifest.outputs.iter()) { + validate_field(name, field)?; + } + let plugin_dir = manifest_path + .parent() + .context("plugin manifest path has no parent")?; + let entrypoint = plugin_dir.join(&manifest.entrypoint); + if !entrypoint.exists() { + bail!("entrypoint missing: {}", entrypoint.display()); + } + if entrypoint.strip_prefix(root).is_err() { + bail!("entrypoint must stay inside project root"); + } + Ok(()) +} + +fn validate_field(name: &str, field: &RegistryField) -> Result<()> { + if let Some(default) = &field.default + && !value_matches_type(default, field.field_type) + { + bail!("default for {name} does not match declared type"); + } + if let Some(values) = &field.enum_values { + for value in values { + if !value_matches_type(value, field.field_type) { + bail!("enum value for {name} does not match declared type"); + } + } + } + Ok(()) +} + +fn to_registry_plugin( + root: &Path, + manifest_path: &Path, + manifest: RegistryPluginManifest, +) -> Result { + let plugin_dir = manifest_path + .parent() + .context("plugin manifest path has no parent")?; + let entrypoint = plugin_dir.join(&manifest.entrypoint); + Ok(RegistryPlugin { + name: manifest.name, + version: manifest.version, + description: manifest.description, + runtime: manifest.runtime, + entrypoint: normalize_path( + entrypoint + .strip_prefix(root) + .context("entrypoint must be inside project root")?, + ), + manifest_path: normalize_path( + manifest_path + .strip_prefix(root) + .context("manifest must be inside project root")?, + ), + plugin_dir: normalize_path( + plugin_dir + .strip_prefix(root) + .context("plugin dir must be inside project root")?, + ), + inputs: manifest.inputs, + outputs: manifest.outputs, + permissions: manifest.permissions, + }) +} + +fn manifest_paths(root: &Path) -> Result> { + let plugins_dir = root.join("plugins"); + if !plugins_dir.exists() { + return Ok(Vec::new()); + } + let mut paths = Vec::new(); + for entry in fs::read_dir(&plugins_dir) + .with_context(|| format!("failed to read {}", plugins_dir.display()))? + { + let entry = entry?; + if entry.file_type()?.is_dir() { + let manifest = entry.path().join("plugin.yml"); + if manifest.exists() { + paths.push(manifest); + } + } + } + paths.sort(); + Ok(paths) +} + +fn registry_hash(plugins: &[RegistryPlugin]) -> Result { + let mut sorted_plugins = plugins.to_vec(); + sorted_plugins.sort_by(|left, right| left.name.cmp(&right.name)); + let value = canonicalize_value(serde_json::to_value(sorted_plugins)?); + let bytes = serde_json::to_vec(&value)?; + let digest = Sha256::digest(bytes); + Ok(format!("sha256:{}", hex_lower(&digest))) +} + +fn canonicalize_value(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.into_iter().map(canonicalize_value).collect()), + Value::Object(map) => { + let sorted = map + .into_iter() + .map(|(key, value)| (key, canonicalize_value(value))) + .collect::>(); + Value::Object(sorted.into_iter().collect::>()) + } + value => value, + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn normalize_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn is_semver(version: &str) -> bool { + let parts = version.split('.').collect::>(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn value_matches_type(value: &Value, field_type: RegistryFieldType) -> bool { + match field_type { + RegistryFieldType::String => value.is_string(), + RegistryFieldType::Integer => value.as_i64().is_some(), + RegistryFieldType::Number => value.is_number(), + RegistryFieldType::Boolean => value.is_boolean(), + RegistryFieldType::Object => value.is_object(), + RegistryFieldType::Array => value.is_array(), + } +} + +fn field_type_map(fields: BTreeMap) -> BTreeMap { + fields + .into_iter() + .map(|(name, field)| (name, field.field_type)) + .collect() +} + +fn agent_permissions(permissions: BTreeMap) -> BTreeMap { + permissions + .into_iter() + .filter_map(|(name, value)| { + value + .get("required") + .and_then(Value::as_bool) + .map(|required| (format!("{name}_required"), required)) + }) + .collect() +} + +fn validate_step_input( + step_index: usize, + input: Option<&Value>, + plugin: &RegistryPlugin, + diagnostics: &mut Vec, +) { + let empty_input = Value::Object(Default::default()); + let input = input.unwrap_or(&empty_input); + let Some(input_object) = input.as_object() else { + diagnostics.push(RegistryLintDiagnostic { + code: "invalid_input_type".to_owned(), + message: "Plugin input must be an object.".to_owned(), + field: format!("steps[{step_index}].input"), + suggestion: None, + }); + return; + }; + + for (name, field) in &plugin.inputs { + if field.required && !input_object.contains_key(name) && field.default.is_none() { + diagnostics.push(RegistryLintDiagnostic { + code: "missing_required_input".to_owned(), + message: format!("Missing required input {name}."), + field: format!("steps[{step_index}].input.{name}"), + suggestion: None, + }); + } + } + + for (name, value) in input_object { + let Some(field) = plugin.inputs.get(name) else { + diagnostics.push(RegistryLintDiagnostic { + code: "unknown_input".to_owned(), + message: format!("Input {name} is not declared by plugin {}.", plugin.name), + field: format!("steps[{step_index}].input.{name}"), + suggestion: None, + }); + continue; + }; + if !value_matches_type(value, field.field_type) { + diagnostics.push(RegistryLintDiagnostic { + code: "invalid_input_type".to_owned(), + message: format!("Input {name} must be {}.", field.field_type.as_str()), + field: format!("steps[{step_index}].input.{name}"), + suggestion: None, + }); + } + } +} + +fn validate_step_entrypoint( + step_index: usize, + step: &crate::dag::StepDefinition, + plugin: &RegistryPlugin, + diagnostics: &mut Vec, +) { + let Some(run) = step.run.as_ref() else { + diagnostics.push(RegistryLintDiagnostic { + code: "entrypoint_mismatch".to_owned(), + message: format!( + "Plugin step must execute registered entrypoint {}.", + plugin.entrypoint + ), + field: format!("steps[{step_index}].run"), + suggestion: Some(format!("Reference {}", plugin.entrypoint)), + }); + return; + }; + + let expected = normalize_registry_path(&plugin.entrypoint); + let command = normalize_registry_path(run.command()); + let args = run + .args() + .iter() + .map(|arg| normalize_registry_path(arg)) + .collect::>(); + + if command != expected && !args.iter().any(|arg| arg == &expected) { + diagnostics.push(RegistryLintDiagnostic { + code: "entrypoint_mismatch".to_owned(), + message: format!( + "Plugin {} must execute registered entrypoint {}.", + plugin.name, plugin.entrypoint + ), + field: format!("steps[{step_index}].run"), + suggestion: Some(format!("Use entrypoint {}", plugin.entrypoint)), + }); + } +} + +fn normalize_registry_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_owned() +} + +pub fn invalid_payload(errors: Vec) -> Value { + json!({ + "status": "invalid", + "errors": errors, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn scans_plugins_and_writes_stable_hash() { + let root = temp_root("registry-scan"); + write_ssl_plugin(&root, "0.1.0"); + + let first = write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + let second = write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + + assert_eq!(first.registry_hash, second.registry_hash); + assert_eq!(first.plugins[0].name, "ssl_check"); + assert_eq!( + first.plugins[0].entrypoint, + "plugins/ssl_check/check_ssl.py" + ); + assert_eq!( + first.plugins[0].manifest_path, + "plugins/ssl_check/plugin.yml" + ); + assert_eq!(first.plugins[0].plugin_dir, "plugins/ssl_check"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn writes_empty_registry_when_no_plugins_exist() { + let root = temp_root("registry-empty"); + + let document = write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + + assert!(document.plugins.is_empty()); + assert!(document.registry_hash.starts_with("sha256:")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn check_fails_when_registry_is_out_of_date() { + let root = temp_root("registry-check"); + write_ssl_plugin(&root, "0.1.0"); + scan_and_write(&root).unwrap(); + write_ssl_plugin(&root, "0.2.0"); + + let error = scan_check(&root).unwrap_err(); + + assert!(error.to_string().contains("registry is out of date")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn registry_hash_changes_when_manifest_changes() { + let root = temp_root("registry-hash"); + write_ssl_plugin(&root, "0.1.0"); + let first = write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + write_ssl_plugin(&root, "0.2.0"); + let second = write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + + assert_ne!(first.registry_hash, second.registry_hash); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn rejects_missing_entrypoint() { + let root = temp_root("registry-entrypoint"); + let plugin_dir = root.join("plugins").join("ssl_check"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("plugin.yml"), manifest("0.1.0")).unwrap(); + + let error = scan_plugins(&root).unwrap_err(); + + assert!(error.to_string().contains("entrypoint missing")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn validates_workflow_plugin_inputs() { + let root = temp_root("registry-lint"); + write_ssl_plugin(&root, "0.1.0"); + write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + let workflow = crate::dag::WorkflowDefinition::from_yaml( + r#" +name: ssl-monitor +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/ssl_check/check_ssl.py"] + plugin_id: ssl_check + input: + port: "443" +"#, + ) + .unwrap(); + + let diagnostics = validate_workflow_plugins(&root, &workflow); + + assert!( + diagnostics + .iter() + .any(|item| item.code == "missing_required_input") + ); + assert!( + diagnostics + .iter() + .any(|item| item.code == "invalid_input_type") + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn accepts_plugin_step_using_registered_entrypoint() { + let root = temp_root("registry-entrypoint-valid"); + write_ssl_plugin(&root, "0.1.0"); + write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + let workflow = crate::dag::WorkflowDefinition::from_yaml( + r#" +name: ssl-monitor +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/ssl_check/check_ssl.py"] + plugin_id: ssl_check + input: + host: api.example.com + port: 443 +"#, + ) + .unwrap(); + + let diagnostics = validate_workflow_plugins(&root, &workflow); + + assert!(diagnostics.is_empty()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn rejects_plugin_step_using_wrong_entrypoint() { + let root = temp_root("registry-entrypoint-invalid"); + write_ssl_plugin(&root, "0.1.0"); + write_registry(&root, scan_plugins(&root).unwrap()).unwrap(); + let workflow = crate::dag::WorkflowDefinition::from_yaml( + r#" +name: ssl-monitor +steps: + - name: check + type: plugin + run: + command: python + args: ["plugins/ssl_check/other.py"] + plugin_id: ssl_check + input: + host: api.example.com + port: 443 +"#, + ) + .unwrap(); + + let diagnostics = validate_workflow_plugins(&root, &workflow); + + assert!( + diagnostics + .iter() + .any(|item| item.code == "entrypoint_mismatch") + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn exports_stable_agent_context() { + let root = temp_root("registry-agent-export"); + write_ssl_plugin(&root, "0.1.0"); + let plugins = scan_plugins(&root).unwrap(); + let document = RegistryDocument { + schema_version: 1, + generated_at: DateTime::parse_from_rfc3339("2026-06-12T10:00:00Z") + .unwrap() + .with_timezone(&Utc), + registry_hash: registry_hash(&plugins).unwrap(), + plugins, + }; + let path = registry_path(&root); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + let context = export_for_agent(&root).unwrap(); + let snapshot = serde_json::to_string_pretty(&context).unwrap(); + + assert_eq!( + snapshot, + r#"{ + "schema_version": 1, + "registry_hash": "sha256:2b72dfd34b9962ea3a96d1364ae89f1476e185cca310b22398cb72f8cbd35c05", + "generated_at": "2026-06-12T10:00:00Z", + "plugins": [ + { + "name": "ssl_check", + "description": "Check SSL/TLS certificate expiration.", + "inputs": { + "host": "string", + "port": "integer" + }, + "outputs": { + "status": "string" + }, + "permissions": { + "network_required": true + } + } + ], + "rules": [ + "Use only registered plugins.", + "Do not invent tools.", + "Return needs_tool if no plugin matches.", + "Do not execute shell directly." + ] +}"# + ); + fs::remove_dir_all(root).ok(); + } + + fn temp_root(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())) + } + + fn write_ssl_plugin(root: &Path, version: &str) { + let plugin_dir = root.join("plugins").join("ssl_check"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("plugin.yml"), manifest(version)).unwrap(); + fs::write(plugin_dir.join("check_ssl.py"), "print('{}')\n").unwrap(); + } + + fn manifest(version: &str) -> String { + format!( + r#"name: ssl_check +version: {version} +description: Check SSL/TLS certificate expiration. +runtime: python +entrypoint: check_ssl.py +inputs: + host: + type: string + required: true + port: + type: integer + default: 443 +outputs: + status: + type: string + enum: [ok, warning, expired, error] +permissions: + network: + required: true + allow_ports: [443] +"# + ) + } +}