From a82bd03126a3726fcb1791bd9e67b476eca317a3 Mon Sep 17 00:00:00 2001 From: Workfly Bot Date: Tue, 9 Jun 2026 20:51:21 +0200 Subject: [PATCH 1/5] docs: define agent mvp spec --- docs/AGENT_MVP_SPEC.md | 450 +++++++++++++++++++++++++++++++++++++++++ docs/DOCS_PLAN.md | 8 +- 2 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 docs/AGENT_MVP_SPEC.md diff --git a/docs/AGENT_MVP_SPEC.md b/docs/AGENT_MVP_SPEC.md new file mode 100644 index 0000000..2d0b3c3 --- /dev/null +++ b/docs/AGENT_MVP_SPEC.md @@ -0,0 +1,450 @@ +# RunFlow Agent MVP Spec + +Status: draft +Target branch: `agent/mvp-spec` +Scope: Agent v1 assist-only + +## Decision + +RunFlow Agent v1 is an assistant, not an operator. + +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. + +## 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 embedded 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 + +Add a new top-level command group: + +```powershell +flow agent +``` + +### `flow agent draft` + +Generate a workflow draft from a user request. + +```powershell +flow agent draft --prompt "Ping 1.1.1.1 every 5 minutes" +flow agent draft --input .\request.txt +flow 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`. + +### `flow agent review` + +Review an existing workflow file. + +```powershell +flow agent review .\workflow.yml +flow 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. + +### `flow agent explain-run` + +Explain a run from structured RunFlow data. + +```powershell +flow agent explain-run +flow 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. + +### `flow agent report daily` + +Generate a local daily report. + +```powershell +flow agent report daily +flow agent report daily --from 2026-06-09T00:00:00Z --to 2026-06-10T00:00:00Z +flow 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 +src/schema_defs/v1/agent-draft.schema.json +src/schema_defs/v1/agent-review.schema.json +src/schema_defs/v1/agent-explain-run.schema.json +src/schema_defs/v1/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: + +```text +src/agent/mod.rs +src/agent/cli.rs +src/agent/config.rs +src/agent/context.rs +src/agent/model.rs +src/agent/output.rs +src/agent/policy.rs +src/agent/report.rs +src/agent/review.rs +src/agent/draft.rs +src/agent/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 must still build and run without Ollama installed. + +## Acceptance Criteria + +V1 is done when: + +- `flow agent draft` generates schema-valid workflow YAML for simple requests; +- `flow agent review` reports schema errors and risk findings without editing files; +- `flow agent explain-run` explains a failed run from local logs/events; +- `flow 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. Add agent CLI group 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 README and docs site sections after the feature is implemented. + +## Open Questions + +- Should `flow 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..5ac18c5 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. Agent MVP spec: assist-only commands, schema validation, policy checks, audit trail Canonical source of truth for the CLI list: `src/cli.rs` enums (`Command`, `JobCommand`, `RunCommand`, etc.). @@ -38,8 +39,9 @@ Canonical source of truth for the CLI list: `src/cli.rs` enums (`Command`, `JobC 9. Plugins: manifest + schémas, règles d’exécution de `plugin test` (cwd/workspace/timeout/validation) 10. Packages (`.flowpkg`): build/install, checksum, emplacement 11. Rétention: dry-run/delete, keep-runs, older-than-days, ce qui est supprimé -12. Dépannage: erreurs typiques + où regarder (`logs/`, `.flow/`) -13. Release: tags + artefacts -14. Dev: fmt/clippy/test/build +12. Spec Agent MVP: commandes assist-only, validation schéma, policy checks, audit trail +13. Dépannage: erreurs typiques + où regarder (`logs/`, `.flow/`) +14. Release: tags + artefacts +15. Dev: fmt/clippy/test/build Source de vérité pour la liste de commandes: `src/cli.rs`. From cd0064019c6cef0eee87ebc5d74a2d4d053bbcc0 Mon Sep 17 00:00:00 2001 From: Workfly Bot Date: Fri, 12 Jun 2026 19:15:54 +0200 Subject: [PATCH 2/5] feat: add local plugin registry --- .flow/registry/plugins.json | 6 + .gitignore | 4 +- Cargo.lock | 66 ++++ Cargo.toml | 1 + README.md | 31 +- docs/AGENT_MVP_SPEC.md | 92 ++--- docs/DOCS_PLAN.md | 10 +- docs/registry.md | 272 ++++++++++++++ schemas/plugin.schema.json | 40 ++ src/cli.rs | 177 ++++++++- src/dag.rs | 2 + src/engine.rs | 5 +- src/lib.rs | 1 + src/registry.rs | 725 ++++++++++++++++++++++++++++++++++++ 14 files changed, 1374 insertions(+), 58 deletions(-) create mode 100644 .flow/registry/plugins.json create mode 100644 docs/registry.md create mode 100644 schemas/plugin.schema.json create mode 100644 src/registry.rs 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/.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..e8f2be4 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,35 @@ 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: {} +``` + +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 index 2d0b3c3..b95b30e 100644 --- a/docs/AGENT_MVP_SPEC.md +++ b/docs/AGENT_MVP_SPEC.md @@ -1,12 +1,13 @@ # RunFlow Agent MVP Spec -Status: draft -Target branch: `agent/mvp-spec` -Scope: Agent v1 assist-only +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 an assistant, not an operator. +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. @@ -17,7 +18,7 @@ It can read RunFlow state, generate drafts, review workflows, explain runs, and - 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. +- Keep RunFlow usable without any model installed and without any LLM dependency. ## Non-goals @@ -28,7 +29,7 @@ It can read RunFlow state, generate drafts, review workflows, explain runs, and - 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 embedded agent. +- No MCP server dependency for the external agent. ## Safety Model @@ -84,20 +85,20 @@ The agent may propose a command, but it must not run it. ## CLI Scope -Add a new top-level command group: +The CLI belongs to the future `runflow-agent` repository. Do not add this command group to `runflow` core. ```powershell -flow agent +runflow-agent ``` -### `flow agent draft` +### `runflow-agent draft` Generate a workflow draft from a user request. ```powershell -flow agent draft --prompt "Ping 1.1.1.1 every 5 minutes" -flow agent draft --input .\request.txt -flow agent draft --prompt "Backup logs" --output .\workflow.yml +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: @@ -111,13 +112,13 @@ Behavior: Accepted workflow fields must match `src/schema_defs/v1/workflow.schema.json`. -### `flow agent review` +### `runflow-agent review` Review an existing workflow file. ```powershell -flow agent review .\workflow.yml -flow agent review .\workflow.yml --format json +runflow-agent review .\workflow.yml +runflow-agent review .\workflow.yml --format json ``` Behavior: @@ -127,13 +128,13 @@ Behavior: - detects risky patterns such as shell redirection, unbounded output, missing timeout, broad env usage, or suspicious commands; - suggests changes without editing the file. -### `flow agent explain-run` +### `runflow-agent explain-run` Explain a run from structured RunFlow data. ```powershell -flow agent explain-run -flow agent explain-run --format json +runflow-agent explain-run +runflow-agent explain-run --format json ``` Inputs: @@ -153,14 +154,14 @@ Behavior: - suggests manual next steps; - never retries or cancels anything. -### `flow agent report daily` +### `runflow-agent report daily` Generate a local daily report. ```powershell -flow agent report daily -flow agent report daily --from 2026-06-09T00:00:00Z --to 2026-06-10T00:00:00Z -flow agent report daily --format json +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: @@ -184,10 +185,10 @@ Agent output must be schema validated before display. Recommended schema files: ```text -src/schema_defs/v1/agent-draft.schema.json -src/schema_defs/v1/agent-review.schema.json -src/schema_defs/v1/agent-explain-run.schema.json -src/schema_defs/v1/agent-report.schema.json +schemas/agent-draft.schema.json +schemas/agent-review.schema.json +schemas/agent-explain-run.schema.json +schemas/agent-report.schema.json ``` ### Draft Response @@ -382,20 +383,19 @@ If later RunFlow gets global events, agent audit can move there. ## Internal Rust Structure -Suggested files: +Suggested files in `runflow-agent`: ```text -src/agent/mod.rs -src/agent/cli.rs -src/agent/config.rs -src/agent/context.rs -src/agent/model.rs -src/agent/output.rs -src/agent/policy.rs -src/agent/report.rs -src/agent/review.rs -src/agent/draft.rs -src/agent/audit.rs +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. @@ -414,16 +414,16 @@ Required behavior: - deterministic settings where possible; - low temperature for YAML/JSON generation. -RunFlow must still build and run without Ollama installed. +RunFlow core must still build and run without Ollama installed because Ollama belongs to `runflow-agent`. ## Acceptance Criteria V1 is done when: -- `flow agent draft` generates schema-valid workflow YAML for simple requests; -- `flow agent review` reports schema errors and risk findings without editing files; -- `flow agent explain-run` explains a failed run from local logs/events; -- `flow agent report daily` produces text and JSON summaries; +- `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; @@ -432,7 +432,7 @@ V1 is done when: ## Implementation Plan -1. Add agent CLI group and config parsing. +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. @@ -440,11 +440,11 @@ V1 is done when: 6. Add run explanation command. 7. Add daily report command. 8. Add audit JSONL. -9. Add README and docs site sections after the feature is implemented. +9. Add `runflow-agent` README sections after the feature is implemented. ## Open Questions -- Should `flow agent draft --output` overwrite by default or require `--force`? +- 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 5ac18c5..7a87a31 100644 --- a/docs/DOCS_PLAN.md +++ b/docs/DOCS_PLAN.md @@ -20,7 +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. Agent MVP spec: assist-only commands, schema validation, policy checks, audit trail +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.). @@ -39,9 +39,9 @@ Canonical source of truth for the CLI list: `src/cli.rs` enums (`Command`, `JobC 9. Plugins: manifest + schémas, règles d’exécution de `plugin test` (cwd/workspace/timeout/validation) 10. Packages (`.flowpkg`): build/install, checksum, emplacement 11. Rétention: dry-run/delete, keep-runs, older-than-days, ce qui est supprimé -12. Spec Agent MVP: commandes assist-only, validation schéma, policy checks, audit trail -13. Dépannage: erreurs typiques + où regarder (`logs/`, `.flow/`) -14. Release: tags + artefacts -15. Dev: fmt/clippy/test/build +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..cce262a --- /dev/null +++ b/docs/registry.md @@ -0,0 +1,272 @@ +# 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`. +- `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 ; +- 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..142db59 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,725 @@ +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_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, + }); + } + } +} + +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(); + } + + 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] +"# + ) + } +} From 460e262f74499006e15700c24797552c0ef03906 Mon Sep 17 00:00:00 2001 From: Workfly Bot Date: Fri, 12 Jun 2026 19:18:29 +0200 Subject: [PATCH 3/5] fix: validate plugin entrypoints against registry --- README.md | 2 + docs/registry.md | 2 + src/registry.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/README.md b/README.md index e8f2be4..c78ff82 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,8 @@ steps: 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 diff --git a/docs/registry.md b/docs/registry.md index cce262a..f5b7262 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -155,6 +155,7 @@ 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. @@ -237,6 +238,7 @@ Verifications v1 : - plugin inconnu ; - registry manquant ; +- entrypoint detourne ou absent ; - input requis absent ; - type d'input incorrect ; - input inconnu. diff --git a/src/registry.rs b/src/registry.rs index 142db59..e02d910 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -325,6 +325,7 @@ pub fn validate_workflow_plugins( continue; }; + validate_step_entrypoint(index, step, plugin, &mut diagnostics); validate_step_input(index, step.input.as_ref(), plugin, &mut diagnostics); } @@ -565,6 +566,50 @@ fn validate_step_input( } } +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", @@ -686,6 +731,66 @@ steps: 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(); + } + fn temp_root(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())) } From 17e1befe5e92ec4cb0e9f2e30e9aa61349b6e3e4 Mon Sep 17 00:00:00 2001 From: Workfly Bot Date: Fri, 12 Jun 2026 19:19:27 +0200 Subject: [PATCH 4/5] ci: check generated registry --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) 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 From fe30f8ebfe33a20f3b1a2c638e09adaf6b9b2626 Mon Sep 17 00:00:00 2001 From: Workfly Bot Date: Fri, 12 Jun 2026 19:20:28 +0200 Subject: [PATCH 5/5] test: snapshot registry agent export --- src/registry.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index e02d910..3182933 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -791,6 +791,59 @@ steps: 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())) }