From 6c4e20bf2ecbb42be474f1b73ef135a00e2e7cf8 Mon Sep 17 00:00:00 2001 From: Chen Yu Date: Thu, 12 Mar 2026 15:18:40 +0900 Subject: [PATCH 01/26] feat: add recipe authoring workbench (#116) --- Cargo.lock | 27 + docs/mvp-checklist.md | 10 + ...026-03-11-recipe-platform-executor-plan.md | 153 +++ ...6-03-11-recipe-platform-foundation-plan.md | 170 +++ ...2026-03-11-recipe-platform-runtime-plan.md | 143 +++ ...6-03-12-recipe-authoring-workbench-plan.md | 548 +++++++++ src-tauri/Cargo.toml | 3 +- src-tauri/recipes.json | 167 +++ src-tauri/src/agent_identity.rs | 135 +++ src-tauri/src/cli_runner.rs | 641 ++++++++-- src-tauri/src/commands/agent.rs | 60 +- src-tauri/src/commands/config.rs | 212 +++- src-tauri/src/commands/doctor_assistant.rs | 1 + src-tauri/src/commands/mod.rs | 1042 ++++++++++++++++- src-tauri/src/commands/precheck.rs | 128 +- src-tauri/src/commands/preferences.rs | 1 + src-tauri/src/commands/profiles.rs | 71 +- src-tauri/src/execution_spec.rs | 178 +++ src-tauri/src/execution_spec_tests.rs | 64 + src-tauri/src/history.rs | 109 +- src-tauri/src/lib.rs | 77 +- src-tauri/src/models.rs | 3 + src-tauri/src/recipe.rs | 225 +++- src-tauri/src/recipe_adapter.rs | 464 ++++++++ src-tauri/src/recipe_adapter_tests.rs | 314 +++++ src-tauri/src/recipe_bundle.rs | 103 ++ src-tauri/src/recipe_bundle_tests.rs | 10 + src-tauri/src/recipe_executor.rs | 421 +++++++ src-tauri/src/recipe_executor_tests.rs | 398 +++++++ src-tauri/src/recipe_planner.rs | 77 ++ src-tauri/src/recipe_planner_tests.rs | 97 ++ src-tauri/src/recipe_runtime/mod.rs | 1 + src-tauri/src/recipe_runtime/systemd.rs | 420 +++++++ src-tauri/src/recipe_store.rs | 197 ++++ src-tauri/src/recipe_store_tests.rs | 92 ++ src-tauri/src/recipe_workspace.rs | 153 +++ src-tauri/src/recipe_workspace_tests.rs | 125 ++ src/App.tsx | 81 +- src/components/RecipeCard.tsx | 29 +- src/components/RecipeFormEditor.tsx | 278 +++++ src/components/RecipePlanPreview.tsx | 113 ++ src/components/RecipeSampleParamsForm.tsx | 41 + src/components/RecipeSaveDialog.tsx | 64 + src/components/RecipeSourceEditor.tsx | 45 + src/components/RecipeValidationPanel.tsx | 84 ++ .../__tests__/RecipePlanPreview.test.tsx | 66 ++ .../__tests__/RescueAsciiHeader.test.tsx | 3 +- src/lib/__tests__/guidance.test.ts | 66 +- src/lib/__tests__/recipe-editor-model.test.ts | 82 ++ src/lib/api.ts | 34 +- src/lib/guidance.ts | 56 +- src/lib/recipe-editor-model.ts | 164 +++ src/lib/types.ts | 195 +++ src/lib/use-api.ts | 44 + src/locales/en.json | 128 ++ src/locales/zh.json | 128 ++ src/pages/Cook.tsx | 260 ++-- src/pages/History.tsx | 102 +- src/pages/Orchestrator.tsx | 125 +- src/pages/RecipeStudio.tsx | 680 +++++++++++ src/pages/Recipes.tsx | 304 ++++- src/pages/__tests__/Doctor.test.tsx | 3 +- src/pages/__tests__/History.test.tsx | 101 ++ src/pages/__tests__/Orchestrator.test.tsx | 78 ++ src/pages/__tests__/RecipeStudio.test.tsx | 74 ++ src/pages/__tests__/Recipes.test.tsx | 97 ++ src/pages/__tests__/cook-execution.test.ts | 87 ++ src/pages/__tests__/cook-plan-context.test.ts | 132 +++ src/pages/cook-execution.ts | 57 + src/pages/cook-plan-context.ts | 147 +++ 70 files changed, 10566 insertions(+), 422 deletions(-) create mode 100644 docs/plans/2026-03-11-recipe-platform-executor-plan.md create mode 100644 docs/plans/2026-03-11-recipe-platform-foundation-plan.md create mode 100644 docs/plans/2026-03-11-recipe-platform-runtime-plan.md create mode 100644 docs/plans/2026-03-12-recipe-authoring-workbench-plan.md create mode 100644 src-tauri/src/agent_identity.rs create mode 100644 src-tauri/src/execution_spec.rs create mode 100644 src-tauri/src/execution_spec_tests.rs create mode 100644 src-tauri/src/recipe_adapter.rs create mode 100644 src-tauri/src/recipe_adapter_tests.rs create mode 100644 src-tauri/src/recipe_bundle.rs create mode 100644 src-tauri/src/recipe_bundle_tests.rs create mode 100644 src-tauri/src/recipe_executor.rs create mode 100644 src-tauri/src/recipe_executor_tests.rs create mode 100644 src-tauri/src/recipe_planner.rs create mode 100644 src-tauri/src/recipe_planner_tests.rs create mode 100644 src-tauri/src/recipe_runtime/mod.rs create mode 100644 src-tauri/src/recipe_runtime/systemd.rs create mode 100644 src-tauri/src/recipe_store.rs create mode 100644 src-tauri/src/recipe_store_tests.rs create mode 100644 src-tauri/src/recipe_workspace.rs create mode 100644 src-tauri/src/recipe_workspace_tests.rs create mode 100644 src/components/RecipeFormEditor.tsx create mode 100644 src/components/RecipePlanPreview.tsx create mode 100644 src/components/RecipeSampleParamsForm.tsx create mode 100644 src/components/RecipeSaveDialog.tsx create mode 100644 src/components/RecipeSourceEditor.tsx create mode 100644 src/components/RecipeValidationPanel.tsx create mode 100644 src/components/__tests__/RecipePlanPreview.test.tsx create mode 100644 src/lib/__tests__/recipe-editor-model.test.ts create mode 100644 src/lib/recipe-editor-model.ts create mode 100644 src/pages/RecipeStudio.tsx create mode 100644 src/pages/__tests__/History.test.tsx create mode 100644 src/pages/__tests__/Orchestrator.test.tsx create mode 100644 src/pages/__tests__/RecipeStudio.test.tsx create mode 100644 src/pages/__tests__/Recipes.test.tsx create mode 100644 src/pages/__tests__/cook-execution.test.ts create mode 100644 src/pages/__tests__/cook-plan-context.test.ts create mode 100644 src/pages/cook-execution.ts create mode 100644 src/pages/cook-plan-context.ts diff --git a/Cargo.lock b/Cargo.lock index 3b1bff67..d8155f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "serde_yaml", "shell-words", "shellexpand", "tauri", @@ -4424,6 +4425,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4467,6 +4481,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -5638,6 +5658,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -5696,6 +5722,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/docs/mvp-checklist.md b/docs/mvp-checklist.md index 06d9e37c..11f6ffd5 100644 --- a/docs/mvp-checklist.md +++ b/docs/mvp-checklist.md @@ -54,3 +54,13 @@ - [x] 每步显示执行结果、错误态重试入口、命令摘要 - [x] 完成 `ready` 后可直接衔接 Doctor/Recipes 配置流程 - [ ] 四种方式接入真实执行器(当前为可审计命令计划与流程骨架) + +## 8. Recipe Authoring Workbench(v0.5) + +- [x] 内置 recipe 可 `Fork to workspace` +- [x] Workspace recipe 支持 `New / Save / Save As / Delete` +- [x] UI 可直接编辑 canonical recipe source,并通过后端做 validate / list / plan +- [x] Studio 支持 sample params 与 live plan preview +- [x] Draft 可直接进入 Cook 并执行 +- [x] Runtime run 可追溯到 `source origin / source digest / workspace path` +- [x] 至少一个 workspace recipe 可在 `Source / Form` 模式之间往返且不丢关键字段 diff --git a/docs/plans/2026-03-11-recipe-platform-executor-plan.md b/docs/plans/2026-03-11-recipe-platform-executor-plan.md new file mode 100644 index 00000000..428a93b9 --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-executor-plan.md @@ -0,0 +1,153 @@ +# Recipe Platform Executor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把已编译的 `ExecutionSpec` 落到现有 local/remote 执行层,优先支持 systemd-backed `job/service/schedule/attachment`。 + +**Architecture:** 这一部分不引入独立的 `reciped` 守护进程,而是把 `ExecutionSpec` 物化成当前系统已经擅长的命令计划。local 复用 `install/runners/local.rs`,remote 复用 `install/runners/remote_ssh.rs` 和现有 SSH/SFTP 能力。 + +**Deferred / Not in phase 1:** 本计划只覆盖 `ExecutionSpec` 到现有 local/SSH runner 的直接物化和执行入口。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;`schedule` 仅下发 systemd timer/unit,不承担持久调度控制面。 + +**Tech Stack:** Rust, systemd, systemd-run, SSH/SFTP, Tauri commands, Cargo tests + +--- + +### Task 1: 新增 ExecutionSpec 执行计划物化层 + +**Files:** +- Create: `src-tauri/src/recipe_executor.rs` +- Create: `src-tauri/src/recipe_runtime/systemd.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn job_spec_materializes_to_systemd_run_command() { + let spec = sample_job_spec(); + let plan = materialize_execution_plan(&spec).unwrap(); + assert!(plan.commands.iter().any(|cmd| cmd.join(" ").contains("systemd-run"))); +} + +#[test] +fn schedule_spec_references_job_launch_ref() { + let spec = sample_schedule_spec(); + let plan = materialize_execution_plan(&spec).unwrap(); + assert!(plan.resources.iter().any(|ref_id| ref_id == "schedule/hourly")); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because the executor layer does not exist. + +**Step 3: Write the minimal implementation** + +- `job` -> `systemd-run --unit clawpal-job-*` +- `service` -> 受控 unit 或 drop-in 文件 +- `schedule` -> `systemd timer` + `job` launch target +- `attachment` -> 先只支持 `systemdDropIn` / `envPatch` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_executor.rs src-tauri/src/recipe_runtime/systemd.rs src-tauri/src/recipe_executor_tests.rs src-tauri/src/lib.rs +git commit -m "feat: materialize recipe specs into systemd execution plans" +``` + +### Task 2: 接入 local / remote runner + +**Files:** +- Modify: `src-tauri/src/install/runners/local.rs` +- Modify: `src-tauri/src/install/runners/remote_ssh.rs` +- Modify: `src-tauri/src/ssh.rs` +- Modify: `src-tauri/src/cli_runner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn local_target_uses_local_runner() { + let route = route_execution(sample_target("local")); + assert_eq!(route.runner, "local"); +} + +#[test] +fn remote_target_uses_remote_ssh_runner() { + let route = route_execution(sample_target("remote")); + assert_eq!(route.runner, "remote_ssh"); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because routing is not implemented. + +**Step 3: Write the minimal implementation** + +- 增加 target routing,把 `ExecutionSpec.target` 路由到 local 或 remote SSH +- 保留现有 command queue 能力,`ExecutionSpec` 只负责生成可执行命令列表 +- 先不支持 workflow、人工审批恢复、后台持久调度 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/install/runners/local.rs src-tauri/src/install/runners/remote_ssh.rs src-tauri/src/ssh.rs src-tauri/src/cli_runner.rs src-tauri/src/commands/mod.rs src-tauri/src/recipe_executor_tests.rs +git commit -m "feat: route recipe execution through local and remote runners" +``` + +### Task 3: 暴露执行入口与最小回滚骨架 + +**Files:** +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn execute_recipe_returns_run_id_and_summary() { + let result = execute_recipe(sample_execution_request()).unwrap(); + assert!(!result.run_id.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because execute API is not exposed. + +**Step 3: Write the minimal implementation** + +- 增加 `execute_recipe` command +- 返回 `runId`, `instanceId`, `summary`, `warnings` +- 回滚只提供骨架入口,先复用现有 config snapshot / rollback 能力 + +**Step 4: Run test to verify it passes** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/commands/mod.rs src/lib/api.ts src/lib/types.ts src-tauri/src/recipe_executor_tests.rs +git commit -m "feat: expose recipe execution api and rollback scaffold" +``` diff --git a/docs/plans/2026-03-11-recipe-platform-foundation-plan.md b/docs/plans/2026-03-11-recipe-platform-foundation-plan.md new file mode 100644 index 00000000..75d5a1ab --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-foundation-plan.md @@ -0,0 +1,170 @@ +# Recipe Platform Foundation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 给 ClawPal 现有 recipe 体系补上 `RecipeBundle -> Runner Contract -> ExecutionSpec` 的基础模型、兼容编译层和 plan preview API。 + +**Architecture:** 第一部分只做“声明、编译、校验、预览”,不做真正的新执行器。现有 `step-based recipe` 继续可用,但后端会多一层 IR,把现有 recipe 编译成结构化 plan,供审批摘要、diff 和执行摘要复用。 + +**Deferred / Not in phase 1:** 本计划只覆盖 bundle/schema、兼容编译、静态校验和 plan preview。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;`secrets` 在这一阶段只保留引用与校验,不引入集中密钥分发或并发协调能力。 + +**Tech Stack:** Tauri 2, Rust, React 18, TypeScript, Bun, Cargo, JSON Schema, YAML/JSON parsing + +--- + +### Task 1: 新增 RecipeBundle 与 ExecutionSpec 核心模型 + +**Files:** +- Create: `src-tauri/src/recipe_bundle.rs` +- Create: `src-tauri/src/execution_spec.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Test: `src-tauri/src/recipe_bundle_tests.rs` +- Test: `src-tauri/src/execution_spec_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn recipe_bundle_rejects_unknown_execution_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +execution: { supportedKinds: [workflow] }"#; + assert!(parse_recipe_bundle(raw).is_err()); +} + +#[test] +fn execution_spec_rejects_inline_secret_value() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +secrets: { bindings: [{ id: "k", source: "plain://abc" }] }"#; + assert!(parse_execution_spec(raw).is_err()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_bundle_tests execution_spec_tests` +Expected: FAIL because the modules do not exist yet. + +**Step 3: Write the minimal implementation** + +- 定义 `RecipeBundle` 最小字段集:`metadata`, `compatibility`, `inputs`, `capabilities`, `resources`, `execution`, `runner`, `outputs` +- 定义 `ExecutionSpec` 最小字段集:`metadata`, `source`, `target`, `execution`, `capabilities`, `resources`, `secrets`, `desired_state`, `actions`, `outputs` +- 先实现 4 个硬约束: + - `execution.kind` 仅允许 `job | service | schedule | attachment` + - secret source 不允许明文协议 + - `usedCapabilities` 不得超出 bundle 上限 + - `claims` 不得出现未知 resource kind + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_bundle_tests execution_spec_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_bundle.rs src-tauri/src/execution_spec.rs src-tauri/src/recipe_bundle_tests.rs src-tauri/src/execution_spec_tests.rs src-tauri/src/lib.rs src/lib/types.ts +git commit -m "feat: add recipe bundle and execution spec primitives" +``` + +### Task 2: 给现有 step-based recipe 增加兼容编译层 + +**Files:** +- Create: `src-tauri/src/recipe_adapter.rs` +- Modify: `src-tauri/src/recipe.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src-tauri/src/recipe_adapter_tests.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn legacy_recipe_compiles_to_attachment_or_job_spec() { + let recipe = builtin_recipes().into_iter().find(|r| r.id == "dedicated-channel-agent").unwrap(); + let spec = compile_legacy_recipe_to_spec(&recipe, sample_params()).unwrap(); + assert!(matches!(spec.execution.kind.as_str(), "attachment" | "job")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test recipe_adapter_tests` +Expected: FAIL because the adapter does not exist. + +**Step 3: Write the minimal implementation** + +- 增加 `compile_legacy_recipe_to_spec(recipe, params)` 入口 +- `config_patch` 映射到 `attachment` 或 `file` 资源 +- `create_agent` / `bind_channel` / `setup_identity` 先映射到 `job` actions +- 保留当前 `recipes.json` 结构,先不引入新的 bundle 文件格式 + +**Step 4: Run test to verify it passes** + +Run: `cargo test recipe_adapter_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_adapter.rs src-tauri/src/recipe.rs src-tauri/src/commands/mod.rs src-tauri/src/recipe_adapter_tests.rs +git commit -m "feat: compile legacy recipes into structured specs" +``` + +### Task 3: 增加 plan preview API 与确认摘要 + +**Files:** +- Create: `src-tauri/src/recipe_planner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Create: `src/components/RecipePlanPreview.tsx` +- Modify: `src/pages/Cook.tsx` +- Test: `src-tauri/src/recipe_planner_tests.rs` +- Test: `src/components/__tests__/RecipePlanPreview.test.tsx` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn plan_recipe_returns_capabilities_claims_and_digest() { + let plan = build_recipe_plan(sample_bundle(), sample_inputs(), sample_facts()).unwrap(); + assert!(!plan.used_capabilities.is_empty()); + assert!(!plan.concrete_claims.is_empty()); + assert!(!plan.execution_spec_digest.is_empty()); +} +``` + +```tsx +it("renders capability and resource summaries in the confirm phase", async () => { + render(); + expect(screen.getByText(/service.manage/i)).toBeInTheDocument(); + expect(screen.getByText(/path/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_planner_tests` +Run: `bun test src/components/__tests__/RecipePlanPreview.test.tsx` +Expected: FAIL because no planning API or preview component exists. + +**Step 3: Write the minimal implementation** + +- 新增 `plan_recipe` Tauri command +- 返回 `summary`, `usedCapabilities`, `concreteClaims`, `executionSpecDigest`, `warnings` +- `Cook.tsx` 确认阶段改为展示结构化计划,而不是只列 step label + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_planner_tests` +Run: `bun test src/components/__tests__/RecipePlanPreview.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_planner.rs src-tauri/src/recipe_planner_tests.rs src-tauri/src/commands/mod.rs src/lib/api.ts src/lib/types.ts src/components/RecipePlanPreview.tsx src/components/__tests__/RecipePlanPreview.test.tsx src/pages/Cook.tsx +git commit -m "feat: add recipe planning preview and approval summary" +``` diff --git a/docs/plans/2026-03-11-recipe-platform-runtime-plan.md b/docs/plans/2026-03-11-recipe-platform-runtime-plan.md new file mode 100644 index 00000000..78e216df --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-runtime-plan.md @@ -0,0 +1,143 @@ +# Recipe Platform Runtime Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在不引入远端守护进程的前提下,先把 `RecipeInstance / Run / Artifact / ResourceClaim` 做成本地可追踪运行时,并接入现有页面。 + +**Architecture:** runtime 数据先落在本地 `.clawpal/recipe-runtime/` 的 JSON index 中,作为 phase 1 临时状态层。这样可以先打通实例列表、运行记录、产物视图和资源占用展示,后续再平滑迁到 VPS 侧 SQLite。 + +**Deferred / Not in phase 1:** 本计划只覆盖本地 `.clawpal/recipe-runtime/` JSON store、实例/运行/产物索引和页面展示。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;任何远端常驻控制面、集中策略决策、集中密钥分发和分布式锁统一留到 phase 2。 + +**Tech Stack:** Rust, Tauri, React 18, TypeScript, JSON persistence, Bun, Cargo + +--- + +### Task 1: 增加运行时 store 与索引模型 + +**Files:** +- Create: `src-tauri/src/recipe_store.rs` +- Modify: `src-tauri/src/models.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/recipe_store_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn record_run_persists_instance_and_artifacts() { + let store = RecipeStore::for_test(); + let run = store.record_run(sample_run()).unwrap(); + assert_eq!(store.list_runs("inst_01").unwrap()[0].id, run.id); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_store_tests` +Expected: FAIL because the runtime store does not exist. + +**Step 3: Write the minimal implementation** + +- 定义 `RecipeInstance`, `Run`, `Artifact`, `ResourceClaim` +- 在 `.clawpal/recipe-runtime/` 下保存最小 JSON index +- 支持 `record_run`, `list_runs`, `list_instances` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_store_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_store.rs src-tauri/src/recipe_store_tests.rs src-tauri/src/models.rs src-tauri/src/lib.rs +git commit -m "feat: add recipe runtime store for instances and runs" +``` + +### Task 2: 把 runtime 数据接到现有页面 + +**Files:** +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/pages/Orchestrator.tsx` +- Modify: `src/pages/History.tsx` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Test: `src/pages/__tests__/Recipes.test.tsx` +- Test: `src/pages/__tests__/Orchestrator.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("shows recipe instance status and recent run summary", async () => { + render( {}} />); + expect(await screen.findByText(/recent run/i)).toBeInTheDocument(); +}); +``` + +```tsx +it("shows artifacts and resource claims in orchestrator", async () => { + render(); + expect(await screen.findByText(/resource claims/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx` +Expected: FAIL because the pages do not render runtime data yet. + +**Step 3: Write the minimal implementation** + +- `Recipes.tsx` 增加实例状态、最近运行、进入 dashboard 的入口 +- `Orchestrator.tsx` 展示 run timeline、artifact 列表、resource claims +- `History.tsx` 只补最小链接,不复制一套新的历史系统 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/Recipes.tsx src/pages/Orchestrator.tsx src/pages/History.tsx src/lib/api.ts src/lib/types.ts src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx +git commit -m "feat: surface recipe runtime state in recipes and orchestrator pages" +``` + +### Task 3: 记录 phase 2 迁移边界,避免 phase 1 过度设计 + +**Files:** +- Modify: `docs/plans/2026-03-11-recipe-platform-foundation-plan.md` +- Modify: `docs/plans/2026-03-11-recipe-platform-executor-plan.md` +- Modify: `docs/plans/2026-03-11-recipe-platform-runtime-plan.md` + +**Step 1: Write the failing check** + +创建一个人工 checklist,逐条确认这 3 份计划没有把以下内容混进 phase 1: +- 远端 `reciped` +- workflow engine +- scheduler durable state +- OPA/Rego policy plane +- secret broker / lock manager + +**Step 2: Run the check** + +Run: `rg -n "reciped|workflow|scheduler|OPA|Rego|secret broker|lock manager" docs/plans/2026-03-11-recipe-platform-*-plan.md` +Expected: only deferred or explicitly excluded references remain. + +**Step 3: Write the minimal implementation** + +- 在 3 份计划中补 “Deferred / Not in phase 1” 边界说明 +- 确保后续执行不会误把第二阶段内容拉进第一阶段 + +**Step 4: Run the check again** + +Run: `rg -n "reciped|workflow|scheduler|OPA|Rego|secret broker|lock manager" docs/plans/2026-03-11-recipe-platform-*-plan.md` +Expected: only deferred references remain. + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-11-recipe-platform-foundation-plan.md docs/plans/2026-03-11-recipe-platform-executor-plan.md docs/plans/2026-03-11-recipe-platform-runtime-plan.md +git commit -m "docs: clarify phase boundaries for recipe runtime rollout" +``` diff --git a/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md b/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md new file mode 100644 index 00000000..f4ec60df --- /dev/null +++ b/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md @@ -0,0 +1,548 @@ +# Recipe Authoring Workbench Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 给 ClawPal 的 Recipe 系统补齐“作者态工作台”,支持 fork 内置 recipe、编辑结构化 source、保存到本地 workspace、校验、预览、试跑,以及把运行记录关联回 recipe source。 + +**Architecture:** 以结构化 recipe source JSON 作为唯一真相,后端负责 parse、validate、plan、save 和 runtime traceability,前端只维护 draft 编辑状态和工作流 UI。内置 recipe 保持只读,通过 `Fork to workspace` 进入工作区;workspace recipe 采用“一文件一个 recipe”的本地模型,默认落到 `~/.clawpal/recipes/workspace/`,保存使用现有原子写入能力。 + +**Tech Stack:** Tauri 2, Rust, React 18, TypeScript, Bun, Cargo, JSON/JSON5 parsing, current RecipeBundle + ExecutionSpec pipeline + +**Deferred / Not in this plan:** 不做远端 recipe 文件编辑,不支持直接写回 HTTP URL source,不做多人协作或云端同步,不做 AST 级 merge/rebase,不做可视化拖拽 builder。 + +## Delivered Notes + +- Status: delivered on branch `chore/recipe-plan-test-fix` +- Task 1 delivered in `d321e81 feat: add recipe workspace storage commands` +- Task 1 test temp-root cleanup follow-up landed in `f4685d4 chore: clean recipe workspace test temp roots` +- Task 2 delivered in `ed17efd feat: add recipe source validation and draft planning` +- Task 3 delivered in `ccb9436 feat: add recipe studio source editor` +- Task 4 delivered in `697c73c feat: add recipe workspace save flows` +- Task 5 delivered in `d0c044e feat: add recipe studio validation and plan sandbox` +- Task 6 delivered in `8268928 feat: execute recipe drafts from studio` +- Task 7 delivered in `b9124bc feat: track recipe source metadata in runtime history` +- Task 8 delivered in `5eff6ad feat: add recipe studio form mode` + +## Final Verification + +- `cargo test recipe_ --lib`: PASS +- `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx`: PASS +- `bun run typecheck`: PASS + +--- + +### Task 1: 建立 workspace recipe 文件模型与后端命令 + +**Files:** +- Create: `src-tauri/src/recipe_workspace.rs` +- Modify: `src-tauri/src/models.rs` +- Modify: `src-tauri/src/config_io.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src-tauri/src/recipe_workspace_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn workspace_recipe_save_writes_under_clawpal_recipe_workspace() { + let store = RecipeWorkspace::for_test(); + let result = store.save_recipe_source("channel-persona", SAMPLE_SOURCE).unwrap(); + assert!(result.path.ends_with("recipes/workspace/channel-persona.recipe.json")); +} + +#[test] +fn workspace_recipe_save_rejects_parent_traversal() { + let store = RecipeWorkspace::for_test(); + assert!(store.save_recipe_source("../escape", SAMPLE_SOURCE).is_err()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_workspace_tests --lib` +Expected: FAIL because the workspace module and commands do not exist. + +**Step 3: Write the minimal implementation** + +- 定义 workspace root:`resolve_paths().clawpal_dir.join("recipes").join("workspace")` +- 增加 `RecipeWorkspace` 负责: + - 规范化 recipe slug + - 解析 recipe 文件路径 + - 原子读写 source text + - 列出 workspace recipe 文件 +- 新增 Tauri commands: + - `list_recipe_workspace_entries` + - `read_recipe_workspace_source` + - `save_recipe_workspace_source` + - `delete_recipe_workspace_source` +- 先不做 rename,使用 `Save As` 覆盖 rename 需求 +- 前端 types 里增加: + - `RecipeWorkspaceEntry` + - `RecipeSourceSaveResult` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_workspace_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_workspace.rs src-tauri/src/models.rs src-tauri/src/config_io.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src-tauri/src/recipe_workspace_tests.rs +git commit -m "feat: add recipe workspace storage commands" +``` + +### Task 2: 增加 raw source 校验、解析和 draft planning API + +**Files:** +- Modify: `src-tauri/src/recipe.rs` +- Modify: `src-tauri/src/recipe_adapter.rs` +- Modify: `src-tauri/src/recipe_planner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src-tauri/src/recipe_adapter_tests.rs` +- Test: `src-tauri/src/recipe_planner_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn exported_recipe_source_validates_as_structured_document() { + let source = export_recipe_source(&builtin_recipe()).unwrap(); + let diagnostics = validate_recipe_source(&source).unwrap(); + assert!(diagnostics.errors.is_empty()); +} + +#[test] +fn plan_recipe_source_uses_unsaved_draft_text() { + let plan = plan_recipe_source("channel-persona", SAMPLE_DRAFT_SOURCE, sample_params()).unwrap(); + assert_eq!(plan.summary.recipe_id, "channel-persona"); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_adapter_tests recipe_planner_tests --lib` +Expected: FAIL because raw source validation and draft planning commands do not exist. + +**Step 3: Write the minimal implementation** + +- 增加基于 source text 的后端入口: + - `validate_recipe_source` + - `list_recipes_from_source_text` + - `plan_recipe_source` +- 诊断结构分三层: + - parse/schema error + - bundle/spec consistency error + - `steps` 与 `actions` 对齐 error +- `plan_recipe_source` 必须支持“未保存 draft”直接预览 +- `export_recipe_source` 继续作为 canonicalization 入口 +- diagnostics 返回结构化位置和消息,不只是一条字符串 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_adapter_tests recipe_planner_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe.rs src-tauri/src/recipe_adapter.rs src-tauri/src/recipe_planner.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src-tauri/src/recipe_adapter_tests.rs src-tauri/src/recipe_planner_tests.rs +git commit -m "feat: add recipe source validation and draft planning" +``` + +### Task 3: 建立 Recipe Studio 路由和 Source Mode 编辑器 + +**Files:** +- Create: `src/pages/RecipeStudio.tsx` +- Create: `src/components/RecipeSourceEditor.tsx` +- Create: `src/components/RecipeValidationPanel.tsx` +- Modify: `src/App.tsx` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/components/RecipeCard.tsx` +- Modify: `src/lib/types.ts` +- Modify: `src/locales/en.json` +- Modify: `src/locales/zh.json` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` +- Test: `src/pages/__tests__/Recipes.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("opens studio from recipes and shows editable source", async () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue(expect.stringContaining('"kind": "ExecutionSpec"')); +}); +``` + +```tsx +it("shows fork button for builtin recipe cards", async () => { + render(); + expect(screen.getByText(/view source/i)).toBeInTheDocument(); + expect(screen.getByText(/fork to workspace/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx` +Expected: FAIL because studio route and source editor do not exist. + +**Step 3: Write the minimal implementation** + +- 新增 `RecipeStudio` 页面,支持: + - source textarea/editor + - dirty state + - current recipe label + - validation summary panel +- `Recipes` 页面增加入口: + - `View source` + - `Edit` + - `Fork to workspace` +- `App.tsx` 增加 recipe studio route 和所需状态: + - `recipeEditorSource` + - `recipeEditorRecipeId` + - `recipeEditorOrigin` +- 内置 recipe 在 studio 中默认只读,fork 后切换为可编辑 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeSourceEditor.tsx src/components/RecipeValidationPanel.tsx src/App.tsx src/pages/Recipes.tsx src/components/RecipeCard.tsx src/lib/types.ts src/locales/en.json src/locales/zh.json src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx +git commit -m "feat: add recipe studio source editor" +``` + +### Task 4: 打通 Save / Save As / New / Delete / Fork 工作流 + +**Files:** +- Modify: `src/pages/RecipeStudio.tsx` +- Create: `src/components/RecipeSaveDialog.tsx` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Modify: `src/lib/types.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` +- Test: `src-tauri/src/recipe_workspace_tests.rs` + +**Step 1: Write the failing tests** + +```tsx +it("marks studio dirty and saves to workspace file", async () => { + render(); + await user.type(screen.getByRole("textbox"), "\n"); + await user.click(screen.getByRole("button", { name: /save/i })); + expect(api.saveRecipeWorkspaceSource).toHaveBeenCalled(); +}); +``` + +```rust +#[test] +fn delete_workspace_recipe_removes_saved_file() { + let store = RecipeWorkspace::for_test(); + let saved = store.save_recipe_source("persona", SAMPLE_SOURCE).unwrap(); + store.delete_recipe_source(saved.slug.as_str()).unwrap(); + assert!(!saved.path.exists()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Run: `cargo test recipe_workspace_tests --lib` +Expected: FAIL because save/delete/fork workflows are incomplete. + +**Step 3: Write the minimal implementation** + +- `RecipeStudio` 支持: + - `New` + - `Save` + - `Save As` + - `Delete` + - `Fork builtin recipe` +- `Save` 仅对 workspace recipe 可用 +- `Save As` 让用户输入 slug;slug 校验在后端做最终裁决 +- 保存成功后重新拉取 `Recipes` 列表,并保持当前 editor 打开的就是保存后的 workspace recipe +- 对未保存离开增加确认 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Run: `cargo test recipe_workspace_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeSaveDialog.tsx src/pages/Recipes.tsx src/lib/api.ts src/lib/use-api.ts src/lib/types.ts src/pages/__tests__/RecipeStudio.test.tsx src-tauri/src/recipe_workspace_tests.rs +git commit -m "feat: add recipe workspace save flows" +``` + +### Task 5: 在 Studio 中加入 live validation 和 sample params sandbox + +**Files:** +- Modify: `src/pages/RecipeStudio.tsx` +- Modify: `src/components/RecipeValidationPanel.tsx` +- Create: `src/components/RecipeSampleParamsForm.tsx` +- Modify: `src/components/RecipePlanPreview.tsx` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("shows planner warnings for unsaved draft source", async () => { + render(); + await user.type(screen.getByLabelText(/persona/i), "Keep answers concise"); + await user.click(screen.getByRole("button", { name: /preview plan/i })); + expect(await screen.findByText(/optional step/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Expected: FAIL because studio cannot preview draft plans yet. + +**Step 3: Write the minimal implementation** + +- 增加 sample params form,优先复用现有 `ParamForm` 的字段渲染逻辑 +- 调用 `validate_recipe_source` 实时显示 diagnostics +- 调用 `plan_recipe_source` 预览 unsaved draft 的结构化 plan +- 复用现有 `RecipePlanPreview` +- 把 parse error、schema error、plan error 分开展示 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeValidationPanel.tsx src/components/RecipeSampleParamsForm.tsx src/components/RecipePlanPreview.tsx src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: add recipe studio validation and plan sandbox" +``` + +### Task 6: 支持 draft recipe 直接进入 Cook 并执行 + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/pages/Cook.tsx` +- Modify: `src/pages/cook-execution.ts` +- Modify: `src/pages/cook-plan-context.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Modify: `src/lib/types.ts` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src/pages/__tests__/cook-execution.test.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("can open cook from studio with unsaved draft source", async () => { + render(); + await user.click(screen.getByRole("button", { name: /cook draft/i })); + expect(mockNavigate).toHaveBeenCalledWith("cook"); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/cook-execution.test.ts` +Expected: FAIL because Cook only accepts saved recipe source/path. + +**Step 3: Write the minimal implementation** + +- `Cook` 增加 `recipeSourceText` 可选输入 +- `listRecipes` / `planRecipe` / `executeRecipe` 补 source-text 变体,允许对 draft 直接编译和执行 +- 保持 Cook 文案和阶段不变,只扩输入来源 +- 如果 draft 未保存,runtime 记录里标记 `sourceOrigin = draft` + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/cook-execution.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/App.tsx src/pages/Cook.tsx src/pages/cook-execution.ts src/pages/cook-plan-context.ts src/lib/api.ts src/lib/use-api.ts src/lib/types.ts src-tauri/src/commands/mod.rs src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: execute recipe drafts from studio" +``` + +### Task 7: 给 runtime run 补 recipe source traceability + +**Files:** +- Modify: `src-tauri/src/recipe_store.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/history.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/pages/Orchestrator.tsx` +- Modify: `src/pages/History.tsx` +- Test: `src-tauri/src/recipe_store_tests.rs` +- Test: `src/pages/__tests__/Recipes.test.tsx` +- Test: `src/pages/__tests__/Orchestrator.test.tsx` +- Test: `src/pages/__tests__/History.test.tsx` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn recorded_run_persists_source_digest_and_origin() { + let store = RecipeStore::for_test(); + let run = sample_run_with_source(); + let recorded = store.record_run(run).unwrap(); + assert_eq!(recorded.source_digest.as_deref(), Some("digest-123")); + assert_eq!(recorded.source_origin.as_deref(), Some("workspace")); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_store_tests --lib` +Expected: FAIL because run metadata does not contain source trace fields. + +**Step 3: Write the minimal implementation** + +- `RecipeRuntimeRun` 增加: + - `sourceDigest` + - `sourceVersion` + - `sourceOrigin` + - `workspacePath` +- `execute_recipe` 在 record run 前写入这些字段 +- `History` / `Orchestrator` / `Recipes` 面板显示“这次运行来自哪份 recipe source” +- 如果 source 来自 workspace,提供“Open in studio”入口 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_store_tests --lib` +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_store.rs src-tauri/src/commands/mod.rs src-tauri/src/history.rs src/lib/types.ts src/pages/Recipes.tsx src/pages/Orchestrator.tsx src/pages/History.tsx src-tauri/src/recipe_store_tests.rs src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx +git commit -m "feat: link runtime runs back to recipe source" +``` + +### Task 8: 增加 Form Mode,并与 canonical source 双向同步 + +**Files:** +- Create: `src/lib/recipe-editor-model.ts` +- Create: `src/components/RecipeFormEditor.tsx` +- Modify: `src/pages/RecipeStudio.tsx` +- Modify: `src/components/RecipeSourceEditor.tsx` +- Modify: `src/lib/types.ts` +- Test: `src/lib/__tests__/recipe-editor-model.test.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```ts +it("round-trips metadata params steps and execution template", () => { + const doc = parseRecipeSource(sampleSource); + const form = toRecipeEditorModel(doc); + const nextDoc = fromRecipeEditorModel(form); + expect(nextDoc.executionSpecTemplate.kind).toBe("ExecutionSpec"); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx` +Expected: FAIL because no form model exists. + +**Step 3: Write the minimal implementation** + +- 定义 canonical editor model,只覆盖: + - top-level metadata + - params + - steps + - action rows + - bundle capability/resource lists +- `RecipeStudio` 增加 `Source / Form` 两个 tab +- 双向同步策略: + - form 修改后重建 canonical source text + - source 修改后重建 form model +- 任一方向 parse 失败时,保留另一侧最后一个有效快照,不做 silent overwrite + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/lib/recipe-editor-model.ts src/components/RecipeFormEditor.tsx src/pages/RecipeStudio.tsx src/components/RecipeSourceEditor.tsx src/lib/types.ts src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: add recipe studio form mode" +``` + +### Task 9: 文档、回归和收尾 + +**Files:** +- Modify: `docs/plans/2026-03-12-recipe-authoring-workbench-plan.md` +- Modify: `docs/mvp-checklist.md` +- Modify: `src/locales/en.json` +- Modify: `src/locales/zh.json` + +**Step 1: Run full relevant verification** + +Run: + +```bash +cargo test recipe_ --lib +bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx +bun run typecheck +``` + +Expected: PASS + +**Step 2: Fix any failing assertions and stale copy** + +- 更新文案、空态、按钮标签 +- 更新 plan 文档中的实际 commit hash +- 把已完成项从 plan 转为 delivered notes + +**Step 3: Commit** + +```bash +git add docs/plans/2026-03-12-recipe-authoring-workbench-plan.md docs/mvp-checklist.md src/locales/en.json src/locales/zh.json +git commit -m "docs: finalize recipe authoring workbench rollout notes" +``` + +--- + +## Recommended Execution Order + +1. Task 1-2 先把 workspace source 和 draft validate/plan API 打通。 +2. Task 3-4 再做 studio 和 save/fork 流程,形成真正 authoring 闭环。 +3. Task 5-6 接上 live preview 和 draft execute,把 authoring 和 Cook 贯通。 +4. Task 7 最后补 runtime traceability,保证运行记录可追溯。 +5. Task 8 作为完整作者体验的最后一层,在 source mode 稳定后再做。 + +## Acceptance Criteria + +- 可以从内置 recipe 一键 fork 到 workspace。 +- 可以在 UI 中直接编辑 canonical recipe source 并保存到本地文件。 +- 可以对未保存 draft 做 validate 和 plan preview。 +- 可以从 draft 直接进入 Cook 并执行。 +- Runtime run 可以追溯到 source digest / source origin / workspace path。 +- 至少一个 workspace recipe 可以通过 Form Mode 与 Source Mode 来回切换而不丢关键字段。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bff4fd99..867eab22 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,9 +15,10 @@ regex = "1.10.6" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.214", features = ["derive"] } serde_json = "1.0.133" +serde_yaml = "0.9" tauri = { version = "2.1.0", features = [] } thiserror = "1.0.63" -uuid = { version = "1.11.0", features = ["v4"] } +uuid = { version = "1.11.0", features = ["v4", "v5"] } chrono = { version = "0.4.38", features = ["clock"] } base64 = "0.22" ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } diff --git a/src-tauri/recipes.json b/src-tauri/recipes.json index 380ba777..cc40d426 100644 --- a/src-tauri/recipes.json +++ b/src-tauri/recipes.json @@ -17,6 +17,103 @@ { "id": "emoji", "label": "Emoji", "type": "string", "required": false, "placeholder": "e.g. \ud83e\udd16", "dependsOn": "independent" }, { "id": "persona", "label": "Persona", "type": "textarea", "required": false, "placeholder": "You are...", "dependsOn": "independent" } ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "dedicated-channel-agent", + "version": "1.0.0", + "description": "Create an agent and bind it to a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.manage", "agent.identity.write", "binding.manage", "config.write"] + }, + "resources": { + "supportedKinds": ["agent", "channel", "file"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "dedicated-channel-agent" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 4 + }, + "actions": [ + { + "kind": "create_agent", + "name": "Create agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}", + "independent": "{{independent}}" + } + }, + { + "kind": "setup_identity", + "name": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "kind": "bind_channel", + "name": "Bind channel to agent", + "args": { + "channelType": "discord", + "peerId": "{{channel_id}}", + "agentId": "{{agent_id}}" + } + }, + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, "steps": [ { "action": "create_agent", "label": "Create agent", "args": { "agentId": "{{agent_id}}", "modelProfileId": "{{model}}", "independent": "{{independent}}" } }, { "action": "setup_identity", "label": "Set agent identity", "args": { "agentId": "{{agent_id}}", "name": "{{name}}", "emoji": "{{emoji}}" } }, @@ -36,6 +133,76 @@ { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, { "id": "persona", "label": "Persona", "type": "textarea", "required": true, "placeholder": "You are..." } ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "discord-channel-persona", + "version": "1.0.0", + "description": "Set a custom persona for a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["config.write"] + }, + "resources": { + "supportedKinds": ["file"] + }, + "execution": { + "supportedKinds": ["attachment"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "discord-channel-persona" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "attachment" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 1 + }, + "actions": [ + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, "steps": [ { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } ] diff --git a/src-tauri/src/agent_identity.rs b/src-tauri/src/agent_identity.rs new file mode 100644 index 00000000..532cb814 --- /dev/null +++ b/src-tauri/src/agent_identity.rs @@ -0,0 +1,135 @@ +use std::fs; +use std::path::Path; + +use serde_json::Value; + +use crate::config_io::read_openclaw_config; +use crate::models::OpenClawPaths; +use crate::ssh::SshConnectionPool; + +fn identity_content(name: &str, emoji: Option<&str>) -> String { + let mut content = format!("- Name: {}\n", name.trim()); + if let Some(emoji) = emoji.map(str::trim).filter(|value| !value.is_empty()) { + content.push_str(&format!("- Emoji: {}\n", emoji)); + } + content +} + +fn resolve_workspace( + cfg: &Value, + agent_id: &str, + default_workspace: Option<&str>, +) -> Result { + clawpal_core::doctor::resolve_agent_workspace_from_config(cfg, agent_id, default_workspace) +} + +pub fn write_local_agent_identity( + paths: &OpenClawPaths, + agent_id: &str, + name: &str, + emoji: Option<&str>, +) -> Result<(), String> { + let cfg = read_openclaw_config(paths)?; + let workspace = resolve_workspace(&cfg, agent_id, None) + .map(|path| shellexpand::tilde(&path).to_string())?; + let workspace_path = Path::new(&workspace); + fs::create_dir_all(workspace_path) + .map_err(|error| format!("Failed to create workspace dir: {}", error))?; + fs::write( + workspace_path.join("IDENTITY.md"), + identity_content(name, emoji), + ) + .map_err(|error| format!("Failed to write IDENTITY.md: {}", error))?; + Ok(()) +} + +fn shell_escape(value: &str) -> String { + let escaped = value.replace('\'', "'\\''"); + format!("'{}'", escaped) +} + +pub async fn write_remote_agent_identity( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, + name: &str, + emoji: Option<&str>, +) -> Result<(), String> { + let (_config_path, _raw, cfg) = + crate::commands::remote_read_openclaw_config_text_and_json(pool, host_id) + .await + .map_err(|error| format!("Failed to parse config: {error}"))?; + + let workspace = resolve_workspace(&cfg, agent_id, Some("~/.openclaw/agents"))?; + let remote_workspace = if workspace.starts_with("~/") { + workspace + } else { + format!("~/{workspace}") + }; + pool.exec( + host_id, + &format!("mkdir -p {}", shell_escape(&remote_workspace)), + ) + .await?; + pool.sftp_write( + host_id, + &format!("{remote_workspace}/IDENTITY.md"), + &identity_content(name, emoji), + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::write_local_agent_identity; + use crate::cli_runner::{set_active_clawpal_data_override, set_active_openclaw_home_override}; + use crate::models::resolve_paths; + use serde_json::json; + use std::fs; + use uuid::Uuid; + + #[test] + fn write_local_agent_identity_creates_identity_file_from_config_workspace() { + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("lobster"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "lobster", + "workspace": workspace.to_string_lossy(), + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = write_local_agent_identity(&resolve_paths(), "lobster", "Lobster", Some("🦞")); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "- Name: Lobster\n- Emoji: 🦞\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } +} diff --git a/src-tauri/src/cli_runner.rs b/src-tauri/src/cli_runner.rs index ef393cd8..ac91c6b8 100644 --- a/src-tauri/src/cli_runner.rs +++ b/src-tauri/src/cli_runner.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Instant; @@ -8,6 +9,7 @@ use serde_json::Value; use uuid::Uuid; use crate::models::resolve_paths; +use crate::recipe_executor::MaterializedExecutionPlan; use crate::ssh::SshConnectionPool; static ACTIVE_OPENCLAW_HOME_OVERRIDE: LazyLock>> = @@ -171,6 +173,131 @@ fn build_remote_openclaw_command(args: &[&str], env: Option<&HashMap String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn allowlisted_systemd_host_command_kind(command: &[String]) -> Option<&'static str> { + match command { + [bin, ..] if bin == "systemd-run" => Some("systemd-run"), + [bin, user, action, ..] + if bin == "systemctl" + && user == "--user" + && matches!(action.as_str(), "stop" | "reset-failed" | "daemon-reload") => + { + Some("systemctl") + } + _ => None, + } +} + +fn is_allowlisted_systemd_host_command(command: &[String]) -> bool { + allowlisted_systemd_host_command_kind(command).is_some() +} + +fn build_remote_shell_command( + command: &[String], + env: Option<&HashMap>, +) -> Result { + if command.is_empty() { + return Err("host command is empty".to_string()); + } + + let mut shell = String::new(); + if let Some(env_vars) = env { + for (key, value) in env_vars { + shell.push_str(&format!("export {}={}; ", key, shell_quote(value))); + } + } + shell.push_str( + &command + .iter() + .map(|part| shell_quote(part)) + .collect::>() + .join(" "), + ); + Ok(shell) +} + +fn run_local_host_command( + command: &[String], + env: Option<&HashMap>, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| "host command is empty".to_string())?; + let mut process = std::process::Command::new(program); + process.args(args); + if let Some(env_vars) = env { + process.envs(env_vars); + } + let output = process.output().map_err(|error| { + format!( + "failed to start host command '{}': {}", + command.join(" "), + error + ) + })?; + Ok(CliOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(1), + }) +} + +fn run_allowlisted_systemd_local_command(command: &[String]) -> Result, String> { + if !is_allowlisted_systemd_host_command(command) { + return Ok(None); + } + run_local_host_command(command, None).map(Some) +} + +async fn run_allowlisted_systemd_remote_command( + pool: &SshConnectionPool, + host_id: &str, + command: &[String], +) -> Result, String> { + if !is_allowlisted_systemd_host_command(command) { + return Ok(None); + } + let shell = build_remote_shell_command(command, None)?; + let output = pool.exec_login(host_id, &shell).await?; + Ok(Some(CliOutput { + stdout: output.stdout, + stderr: output.stderr, + exit_code: output.exit_code as i32, + })) +} + +fn systemd_dropin_relative_path(target: &str, name: &str) -> String { + format!("~/.config/systemd/user/{}.d/{}", target, name) +} + +fn write_local_systemd_dropin(target: &str, name: &str, content: &str) -> Result<(), String> { + let path = + PathBuf::from(shellexpand::tilde(&systemd_dropin_relative_path(target, name)).to_string()); + crate::config_io::write_text(path.as_path(), content) +} + +async fn write_remote_systemd_dropin( + pool: &SshConnectionPool, + host_id: &str, + target: &str, + name: &str, + content: &str, +) -> Result<(), String> { + let dir = format!("~/.config/systemd/user/{}.d", target); + let resolved_dir = pool.resolve_path(host_id, &dir).await?; + pool.exec(host_id, &format!("mkdir -p {}", shell_quote(&resolved_dir))) + .await?; + pool.sftp_write( + host_id, + &systemd_dropin_relative_path(target, name), + content, + ) + .await +} + pub fn parse_json_output(output: &CliOutput) -> Result { clawpal_core::openclaw::parse_json_output(output).map_err(|e| e.to_string()) } @@ -200,6 +327,51 @@ mod tests { assert!(cmd.contains(" 'a'\\''b'")); } + #[test] + fn allowlisted_systemd_host_commands_are_restricted_to_expected_shapes() { + assert!(is_allowlisted_systemd_host_command(&[ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ])); + assert!(is_allowlisted_systemd_host_command(&[ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ])); + assert!(!is_allowlisted_systemd_host_command(&[ + "systemctl".into(), + "--system".into(), + "daemon-reload".into(), + ])); + assert!(!is_allowlisted_systemd_host_command(&[ + "bash".into(), + "-lc".into(), + "echo nope".into(), + ])); + } + + #[test] + fn rollback_command_supports_snapshot_id_prefix() { + let command = vec![ + "__rollback__".to_string(), + "snapshot_01".to_string(), + "{\"ok\":true}".to_string(), + ]; + + assert_eq!( + rollback_command_snapshot_id(&command).as_deref(), + Some("snapshot_01") + ); + assert_eq!( + rollback_command_content(&command).expect("rollback content"), + "{\"ok\":true}" + ); + } + #[test] fn preview_direct_apply_handles_config_set_and_unset_with_arrays() { let mut config = json!({ @@ -357,6 +529,54 @@ mod tests { assert!(result.is_none()); } + #[test] + fn preview_direct_apply_skips_allowlisted_systemd_commands() { + let mut config = json!({"gateway": {"port": 18789}}); + let host_cmd = PendingCommand { + id: "1".into(), + label: "Run hourly job".into(), + command: vec![ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ], + created_at: String::new(), + }; + + let touched = apply_direct_preview_command(&mut config, &host_cmd) + .expect("preview should accept allowlisted host command") + .expect("host command should be handled directly"); + + assert_eq!(config["gateway"]["port"], json!(18789)); + assert!(!touched.agents && !touched.channels && !touched.bindings && !touched.generic); + } + + #[test] + fn preview_direct_apply_skips_internal_systemd_dropin_write_command() { + let mut config = json!({"gateway": {"port": 18789}}); + let host_cmd = PendingCommand { + id: "1".into(), + label: "Write drop-in".into(), + command: vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + "openclaw-gateway.service".into(), + "10-env.conf".into(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord".into(), + ], + created_at: String::new(), + }; + + let touched = apply_direct_preview_command(&mut config, &host_cmd) + .expect("preview should accept internal drop-in write") + .expect("drop-in write should be handled directly"); + + assert_eq!(config["gateway"]["port"], json!(18789)); + assert!(!touched.agents && !touched.channels && !touched.bindings && !touched.generic); + } + #[test] fn preview_side_effect_warning_marks_agent_commands() { let add_cmd = PendingCommand { @@ -389,6 +609,41 @@ mod tests { .expect("delete warning") .contains("filesystem cleanup")); } + + #[test] + fn preview_side_effect_warning_marks_systemd_commands() { + let host_cmd = PendingCommand { + id: "1".into(), + label: "Run hourly job".into(), + command: vec![ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ], + created_at: String::new(), + }; + let drop_in_cmd = PendingCommand { + id: "2".into(), + label: "Write drop-in".into(), + command: vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + "openclaw-gateway.service".into(), + "10-env.conf".into(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord".into(), + ], + created_at: String::new(), + }; + + assert!(preview_side_effect_warning(&host_cmd) + .expect("systemd warning") + .contains("host-side systemd changes")); + assert!(preview_side_effect_warning(&drop_in_cmd) + .expect("drop-in warning") + .contains("does not write systemd drop-in")); + } } // --------------------------------------------------------------------------- @@ -457,6 +712,26 @@ impl Default for CommandQueue { } } +pub fn enqueue_materialized_plan( + queue: &CommandQueue, + plan: &MaterializedExecutionPlan, +) -> Vec { + plan.commands + .iter() + .enumerate() + .map(|(index, command)| { + let label = format!( + "[{}] {} ({}/{})", + plan.execution_kind, + plan.unit_name, + index + 1, + plan.commands.len() + ); + queue.enqueue(label, command.clone()) + }) + .collect() +} + // --------------------------------------------------------------------------- // Tauri commands — Task 3 // --------------------------------------------------------------------------- @@ -807,6 +1082,9 @@ fn apply_direct_preview_command( }; match first { + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND => { + return Ok(Some(PreviewTouchedDomains::default())); + } "__config_write__" | "__rollback__" => { let Some(content) = cmd.command.get(1) else { return Err(format!("{}: missing config payload", cmd.label)); @@ -817,6 +1095,9 @@ fn apply_direct_preview_command( return Ok(Some(touched)); } "openclaw" => {} + _ if is_allowlisted_systemd_host_command(&cmd.command) => { + return Ok(Some(PreviewTouchedDomains::default())); + } _ => return Ok(None), } @@ -901,23 +1182,44 @@ fn apply_direct_preview_command( } fn preview_side_effect_warning(cmd: &PendingCommand) -> Option { + if cmd.command.first().map(|value| value.as_str()) + == Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) + { + let target = cmd.command.get(1).map(String::as_str).unwrap_or("systemd"); + let name = cmd.command.get(2).map(String::as_str).unwrap_or("drop-in"); + return Some(format!( + "{}: preview does not write systemd drop-in '{}:{}'; file creation will run during apply.", + cmd.label, target, name + )); + } + + if let Some(kind) = allowlisted_systemd_host_command_kind(&cmd.command) { + return Some(format!( + "{}: preview does not execute allowlisted {} command '{}'; host-side systemd changes will run during apply.", + cmd.label, + kind, + cmd.command.join(" ") + )); + } + let [bin, category, action, target, ..] = cmd.command.as_slice() else { return None; }; - if bin != "openclaw" || category != "agents" { - return None; - } - match action.as_str() { - "add" => Some(format!( - "{}: preview only validates config changes; agent workspace/filesystem setup for '{}' will run during apply.", - cmd.label, target - )), - "delete" => Some(format!( - "{}: preview only validates config changes; any filesystem cleanup for '{}' is not simulated.", - cmd.label, target - )), - _ => None, + if bin == "openclaw" && category == "agents" { + return match action.as_str() { + "add" => Some(format!( + "{}: preview only validates config changes; agent workspace/filesystem setup for '{}' will run during apply.", + cmd.label, target + )), + "delete" => Some(format!( + "{}: preview only validates config changes; any filesystem cleanup for '{}' is not simulated.", + cmd.label, target + )), + _ => None, + }; } + + None } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1196,10 +1498,147 @@ pub struct ApplyQueueResult { pub rolled_back: bool, } +fn rollback_command_snapshot_id(command: &[String]) -> Option { + if command.first().map(|value| value.as_str()) != Some("__rollback__") { + return None; + } + if command.len() >= 3 { + return command + .get(1) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + None +} + +fn rollback_command_content(command: &[String]) -> Result { + match command.first().map(|value| value.as_str()) { + Some("__rollback__") if command.len() >= 3 => command + .get(2) + .cloned() + .ok_or_else(|| "internal rollback is missing content".to_string()), + Some("__rollback__") | Some("__config_write__") => command + .get(1) + .cloned() + .ok_or_else(|| "internal config write is missing content".to_string()), + _ => command + .get(1) + .cloned() + .ok_or_else(|| "internal config write is missing content".to_string()), + } +} + +fn apply_internal_local_command( + paths: &crate::models::OpenClawPaths, + command: &[String], +) -> Result { + fn content(command: &[String]) -> Result { + rollback_command_content(command) + } + match command.first().map(|value| value.as_str()) { + Some("__config_write__") | Some("__rollback__") => { + let content = content(command)?; + crate::config_io::write_text(&paths.config_path, &content)?; + Ok(true) + } + Some(crate::commands::INTERNAL_SETUP_IDENTITY_COMMAND) => { + let agent_id = command + .get(1) + .ok_or_else(|| "setup_identity command missing agent id".to_string())?; + let name = command + .get(2) + .ok_or_else(|| "setup_identity command missing name".to_string())?; + crate::agent_identity::write_local_agent_identity( + paths, + agent_id, + name, + command.get(3).map(String::as_str), + )?; + Ok(true) + } + Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) => { + let target = command + .get(1) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing target unit".to_string())?; + let name = command + .get(2) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing name".to_string())?; + let content = command + .get(3) + .map(String::as_str) + .ok_or_else(|| "systemd drop-in command missing content".to_string())?; + write_local_systemd_dropin(target, name, content)?; + Ok(true) + } + _ => Ok(false), + } +} + +async fn apply_internal_remote_command( + pool: &SshConnectionPool, + host_id: &str, + command: &[String], +) -> Result { + fn content(command: &[String]) -> Result { + rollback_command_content(command) + } + match command.first().map(|value| value.as_str()) { + Some("__config_write__") | Some("__rollback__") => { + let content = content(command)?; + pool.sftp_write(host_id, "~/.openclaw/openclaw.json", &content) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_SETUP_IDENTITY_COMMAND) => { + let agent_id = command + .get(1) + .ok_or_else(|| "setup_identity command missing agent id".to_string())?; + let name = command + .get(2) + .ok_or_else(|| "setup_identity command missing name".to_string())?; + crate::agent_identity::write_remote_agent_identity( + pool, + host_id, + agent_id, + name, + command.get(3).map(String::as_str), + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) => { + let target = command + .get(1) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing target unit".to_string())?; + let name = command + .get(2) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing name".to_string())?; + let content = command + .get(3) + .map(String::as_str) + .ok_or_else(|| "systemd drop-in command missing content".to_string())?; + write_remote_systemd_dropin(pool, host_id, target, name, content).await?; + Ok(true) + } + _ => Ok(false), + } +} + #[tauri::command] pub async fn apply_queued_commands( queue: tauri::State<'_, CommandQueue>, cache: tauri::State<'_, CliCache>, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, ) -> Result { let commands = queue.list(); if commands.is_empty() { @@ -1232,47 +1671,57 @@ pub async fn apply_queued_commands( .any(|c| c.command.first().map(|s| s.as_str()) == Some("__rollback__")); let source = if is_rollback { "rollback" } else { "clawpal" }; let can_rollback = !is_rollback; + let snapshot_recipe_id = snapshot_recipe_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(summary); let _ = crate::history::add_snapshot( &paths.history_dir, &paths.metadata_path, - Some(summary), + Some(snapshot_recipe_id), source, can_rollback, &config_before, + run_id.clone(), None, + snapshot_artifacts.clone().unwrap_or_default(), ); // Execute each command for real let mut applied_count = 0; for cmd in &commands { - if matches!( - cmd.command.first().map(|s| s.as_str()), - Some("__config_write__") | Some("__rollback__") - ) { - // Internal command: write config content directly - if let Some(content) = cmd.command.get(1) { - if let Err(e) = crate::config_io::write_text(&paths.config_path, content) { - let _ = crate::config_io::write_text(&paths.config_path, &config_before); - queue_handle.clear(); - return Ok(ApplyQueueResult { - ok: false, - applied_count, - total_count, - error: Some(format!( - "Step {} failed ({}): {}", - applied_count + 1, - cmd.label, - e - )), - rolled_back: true, - }); - } + match apply_internal_local_command(&paths, &cmd.command) { + Ok(true) => { + applied_count += 1; + continue; + } + Ok(false) => {} + Err(e) => { + let _ = crate::config_io::write_text(&paths.config_path, &config_before); + queue_handle.clear(); + return Ok(ApplyQueueResult { + ok: false, + applied_count, + total_count, + error: Some(format!( + "Step {} failed ({}): {}", + applied_count + 1, + cmd.label, + e + )), + rolled_back: true, + }); } - applied_count += 1; - continue; } - let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); - let result = run_openclaw(&args); + let result = match run_allowlisted_systemd_local_command(&cmd.command) { + Ok(Some(output)) => Ok(output), + Ok(None) => { + let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); + run_openclaw(&args) + } + Err(error) => Err(error), + }; match result { Ok(output) if output.exit_code != 0 => { let detail = if !output.stderr.is_empty() { @@ -1412,6 +1861,27 @@ impl Default for RemoteCommandQueues { } } +pub fn enqueue_materialized_plan_remote( + queues: &RemoteCommandQueues, + host_id: &str, + plan: &MaterializedExecutionPlan, +) -> Vec { + plan.commands + .iter() + .enumerate() + .map(|(index, command)| { + let label = format!( + "[{}] {} ({}/{})", + plan.execution_kind, + plan.unit_name, + index + 1, + plan.commands.len() + ); + queues.enqueue(host_id, label, command.clone()) + }) + .collect() +} + // --------------------------------------------------------------------------- // Remote queue management Tauri commands // --------------------------------------------------------------------------- @@ -1732,6 +2202,9 @@ pub async fn remote_apply_queued_commands( pool: tauri::State<'_, SshConnectionPool>, queues: tauri::State<'_, RemoteCommandQueues>, host_id: String, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, ) -> Result { let commands = queues.list(&host_id); if commands.is_empty() { @@ -1771,44 +2244,70 @@ pub async fn remote_apply_queued_commands( let _ = pool .sftp_write(&host_id, &snapshot_path, &config_before) .await; + let snapshot_recipe_id = snapshot_recipe_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(summary.clone()); + let snapshot_created_at = chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| ts.to_string()); + let _ = crate::commands::config::record_remote_snapshot_metadata( + &pool, + &host_id, + crate::history::SnapshotMeta { + id: snapshot_filename.clone(), + recipe_id: Some(snapshot_recipe_id), + created_at: snapshot_created_at, + config_path: snapshot_path.clone(), + source: source.into(), + can_rollback: !is_rollback, + run_id: run_id.clone(), + rollback_of: None, + artifacts: snapshot_artifacts.clone().unwrap_or_default(), + }, + ) + .await; // Execute each command let mut applied_count = 0; for cmd in &commands { - // Handle internal commands (__config_write__, __rollback__) — write config directly - if matches!( - cmd.command.first().map(|s| s.as_str()), - Some("__config_write__") | Some("__rollback__") - ) { - if let Some(content) = cmd.command.get(1) { - if let Err(e) = pool - .sftp_write(&host_id, "~/.openclaw/openclaw.json", content) - .await - { - let _ = pool - .sftp_write(&host_id, "~/.openclaw/openclaw.json", &config_before) - .await; - queues.clear(&host_id); - return Ok(ApplyQueueResult { - ok: false, - applied_count, - total_count, - error: Some(format!( - "Step {} failed ({}): {}", - applied_count + 1, - cmd.label, - e - )), - rolled_back: true, - }); - } + match apply_internal_remote_command(&pool, &host_id, &cmd.command).await { + Ok(true) => { + applied_count += 1; + continue; + } + Ok(false) => {} + Err(e) => { + let _ = pool + .sftp_write(&host_id, "~/.openclaw/openclaw.json", &config_before) + .await; + queues.clear(&host_id); + return Ok(ApplyQueueResult { + ok: false, + applied_count, + total_count, + error: Some(format!( + "Step {} failed ({}): {}", + applied_count + 1, + cmd.label, + e + )), + rolled_back: true, + }); } - applied_count += 1; - continue; } - let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); - match run_openclaw_remote(&pool, &host_id, &args).await { + let result = + match run_allowlisted_systemd_remote_command(&pool, &host_id, &cmd.command).await { + Ok(Some(output)) => Ok(output), + Ok(None) => { + let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); + run_openclaw_remote(&pool, &host_id, &args).await + } + Err(error) => Err(error), + }; + match result { Ok(output) if output.exit_code != 0 => { let detail = if !output.stderr.is_empty() { output.stderr.clone() diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index be9722b6..3b3306f6 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -16,38 +16,14 @@ pub async fn remote_setup_agent_identity( if name.is_empty() { return Err("Name is required".into()); } - - // Read remote config to find agent workspace - let (_config_path, _raw, cfg) = remote_read_openclaw_config_text_and_json(&pool, &host_id) - .await - .map_err(|e| format!("Failed to parse config: {e}"))?; - - let workspace = clawpal_core::doctor::resolve_agent_workspace_from_config( - &cfg, + crate::agent_identity::write_remote_agent_identity( + pool.inner(), + &host_id, &agent_id, - Some("~/.openclaw/agents"), - )?; - - // Build IDENTITY.md content - let mut content = format!("- Name: {}\n", name); - if let Some(ref e) = emoji { - let e = e.trim(); - if !e.is_empty() { - content.push_str(&format!("- Emoji: {}\n", e)); - } - } - - // Write via SSH - let ws = if workspace.starts_with("~/") { - workspace.to_string() - } else { - format!("~/{workspace}") - }; - pool.exec(&host_id, &format!("mkdir -p {}", shell_escape(&ws))) - .await?; - let identity_path = format!("{}/IDENTITY.md", ws); - pool.sftp_write(&host_id, &identity_path, &content).await?; - + &name, + emoji.as_deref(), + ) + .await?; Ok(true) } @@ -230,27 +206,7 @@ pub fn setup_agent_identity( } let paths = resolve_paths(); - let cfg = read_openclaw_config(&paths)?; - - let workspace = - clawpal_core::doctor::resolve_agent_workspace_from_config(&cfg, &agent_id, None) - .map(|s| expand_tilde(&s))?; - - // Build IDENTITY.md content - let mut content = format!("- Name: {}\n", name); - if let Some(ref e) = emoji { - let e = e.trim(); - if !e.is_empty() { - content.push_str(&format!("- Emoji: {}\n", e)); - } - } - - let ws_path = std::path::Path::new(&workspace); - fs::create_dir_all(ws_path).map_err(|e| format!("Failed to create workspace dir: {}", e))?; - let identity_path = ws_path.join("IDENTITY.md"); - fs::write(&identity_path, &content) - .map_err(|e| format!("Failed to write IDENTITY.md: {}", e))?; - + crate::agent_identity::write_local_agent_identity(&paths, &agent_id, &name, emoji.as_deref())?; Ok(true) } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 9182d872..65b0853c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1,5 +1,100 @@ use super::*; +const REMOTE_SNAPSHOT_METADATA_PATH: &str = "~/.clawpal/metadata.json"; + +fn history_page_from_snapshot_index(index: crate::history::SnapshotIndex) -> HistoryPage { + HistoryPage { + items: index + .items + .into_iter() + .map(|item| HistoryItem { + id: item.id, + recipe_id: item.recipe_id, + created_at: item.created_at, + source: item.source, + can_rollback: item.can_rollback, + run_id: item.run_id, + rollback_of: item.rollback_of, + artifacts: item.artifacts, + }) + .collect(), + } +} + +fn fallback_snapshot_meta_from_remote_entry( + entry: &crate::ssh::SftpEntry, +) -> Option { + if entry.name.starts_with('.') || entry.is_dir { + return None; + } + let stem = entry.name.trim_end_matches(".json"); + let parts: Vec<&str> = stem.splitn(3, '-').collect(); + let ts_str = parts.first().copied().unwrap_or("0"); + let source = parts.get(1).copied().unwrap_or("unknown"); + let recipe_id = parts.get(2).map(|s| s.to_string()); + let created_at = ts_str.parse::().unwrap_or(0); + let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| created_at.to_string()); + Some(crate::history::SnapshotMeta { + id: entry.name.clone(), + recipe_id, + created_at: created_at_iso, + config_path: format!("~/.clawpal/snapshots/{}", entry.name), + source: source.to_string(), + can_rollback: source != "rollback", + run_id: None, + rollback_of: None, + artifacts: Vec::new(), + }) +} + +pub(crate) async fn read_remote_snapshot_index( + pool: &SshConnectionPool, + host_id: &str, +) -> Result { + match pool.sftp_read(host_id, REMOTE_SNAPSHOT_METADATA_PATH).await { + Ok(text) => crate::history::parse_snapshot_index_text(&text), + Err(error) if super::is_remote_missing_path_error(&error) => { + Ok(crate::history::SnapshotIndex::default()) + } + Err(error) => Err(format!( + "Failed to read remote snapshot metadata: {}", + error + )), + } +} + +pub(crate) async fn write_remote_snapshot_index( + pool: &SshConnectionPool, + host_id: &str, + index: &crate::history::SnapshotIndex, +) -> Result<(), String> { + pool.exec(host_id, "mkdir -p ~/.clawpal").await?; + let text = crate::history::render_snapshot_index_text(index)?; + pool.sftp_write(host_id, REMOTE_SNAPSHOT_METADATA_PATH, &text) + .await +} + +pub(crate) async fn record_remote_snapshot_metadata( + pool: &SshConnectionPool, + host_id: &str, + snapshot: crate::history::SnapshotMeta, +) -> Result<(), String> { + let mut index = read_remote_snapshot_index(pool, host_id).await?; + crate::history::upsert_snapshot(&mut index, snapshot); + write_remote_snapshot_index(pool, host_id, &index).await +} + +async fn resolve_remote_snapshot_meta( + pool: &SshConnectionPool, + host_id: &str, + snapshot_id: &str, +) -> Result, String> { + let index = read_remote_snapshot_index(pool, host_id).await?; + Ok(crate::history::find_snapshot(&index, snapshot_id).cloned()) +} + #[tauri::command] pub async fn remote_read_raw_config( pool: State<'_, SshConnectionPool>, @@ -68,42 +163,25 @@ pub async fn remote_apply_config_patch( pub async fn remote_list_history( pool: State<'_, SshConnectionPool>, host_id: String, -) -> Result { +) -> Result { // Ensure dir exists pool.exec(&host_id, "mkdir -p ~/.clawpal/snapshots").await?; let entries = pool.sftp_list(&host_id, "~/.clawpal/snapshots").await?; - let mut items: Vec = Vec::new(); + let mut index = read_remote_snapshot_index(&pool, &host_id).await?; + let known_ids = index + .items + .iter() + .map(|item| item.id.clone()) + .collect::>(); for entry in entries { - if entry.name.starts_with('.') || entry.is_dir { + if known_ids.contains(&entry.name) { continue; } - // Parse filename: {unix_ts}-{source}-{summary}.json - let stem = entry.name.trim_end_matches(".json"); - let parts: Vec<&str> = stem.splitn(3, '-').collect(); - let ts_str = parts.first().unwrap_or(&"0"); - let source = parts.get(1).unwrap_or(&"unknown"); - let recipe_id = parts.get(2).map(|s| s.to_string()); - let created_at = ts_str.parse::().unwrap_or(0); - // Convert Unix timestamp to ISO 8601 format for frontend compatibility - let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| created_at.to_string()); - let is_rollback = *source == "rollback"; - items.push(serde_json::json!({ - "id": entry.name, - "recipeId": recipe_id, - "createdAt": created_at_iso, - "source": source, - "canRollback": !is_rollback, - })); + if let Some(snapshot) = fallback_snapshot_meta_from_remote_entry(&entry) { + crate::history::upsert_snapshot(&mut index, snapshot); + } } - // Sort newest first - items.sort_by(|a, b| { - let ta = a["createdAt"].as_str().unwrap_or(""); - let tb = b["createdAt"].as_str().unwrap_or(""); - tb.cmp(ta) - }); - Ok(serde_json::json!({ "items": items })) + Ok(history_page_from_snapshot_index(index)) } #[tauri::command] @@ -112,7 +190,10 @@ pub async fn remote_preview_rollback( host_id: String, snapshot_id: String, ) -> Result { - let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}"); + let snapshot_path = resolve_remote_snapshot_meta(&pool, &host_id, &snapshot_id) + .await? + .map(|snapshot| snapshot.config_path) + .unwrap_or_else(|| format!("~/.clawpal/snapshots/{snapshot_id}")); let snapshot_text = pool.sftp_read(&host_id, &snapshot_path).await?; let target = clawpal_core::config::validate_config_json(&snapshot_text) .map_err(|e| format!("Failed to parse snapshot: {e}"))?; @@ -143,13 +224,21 @@ pub async fn remote_rollback( host_id: String, snapshot_id: String, ) -> Result { - let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}"); + let snapshot_meta = resolve_remote_snapshot_meta(&pool, &host_id, &snapshot_id).await?; + let snapshot_path = snapshot_meta + .as_ref() + .map(|snapshot| snapshot.config_path.clone()) + .unwrap_or_else(|| format!("~/.clawpal/snapshots/{snapshot_id}")); let target_text = pool.sftp_read(&host_id, &snapshot_path).await?; let target = clawpal_core::config::validate_config_json(&target_text) .map_err(|e| format!("Failed to parse snapshot: {e}"))?; let (config_path, current_text, _current) = remote_read_openclaw_config_text_and_json(&pool, &host_id).await?; + let mut warnings = Vec::new(); + if let Some(snapshot) = snapshot_meta.as_ref() { + warnings.extend(super::cleanup_remote_recipe_snapshot(&pool, &host_id, snapshot).await); + } remote_write_config_with_snapshot( &pool, &host_id, @@ -165,7 +254,7 @@ pub async fn remote_rollback( snapshot_id: Some(snapshot_id), config_path, backup_path: None, - warnings: vec!["rolled back".into()], + warnings, errors: Vec::new(), }) } @@ -194,6 +283,8 @@ pub fn apply_config_patch( true, ¤t_text, None, + None, + Vec::new(), )?; let (candidate, _changes) = build_candidate_config_from_template(¤t, &patch_template, ¶ms)?; @@ -216,19 +307,11 @@ pub fn apply_config_patch( pub fn list_history(limit: usize, offset: usize) -> Result { let paths = resolve_paths(); let index = list_snapshots(&paths.metadata_path)?; - let items = index + let items = history_page_from_snapshot_index(index) .items .into_iter() .skip(offset) .take(limit) - .map(|item| HistoryItem { - id: item.id, - recipe_id: item.recipe_id, - created_at: item.created_at, - source: item.source, - can_rollback: item.can_rollback, - rollback_of: item.rollback_of, - }) .collect(); Ok(HistoryPage { items }) } @@ -280,6 +363,7 @@ pub fn rollback(snapshot_id: String) -> Result { let target_text = read_snapshot(&target.config_path)?; let backup = read_openclaw_config(&paths)?; let backup_text = serde_json::to_string_pretty(&backup).map_err(|e| e.to_string())?; + let warnings = super::cleanup_local_recipe_snapshot(&target); let _ = add_snapshot( &paths.history_dir, &paths.metadata_path, @@ -287,7 +371,9 @@ pub fn rollback(snapshot_id: String) -> Result { "rollback", true, &backup_text, + None, Some(target.id.clone()), + Vec::new(), )?; write_text(&paths.config_path, &target_text)?; Ok(ApplyResult { @@ -295,7 +381,49 @@ pub fn rollback(snapshot_id: String) -> Result { snapshot_id: Some(target.id), config_path: paths.config_path.to_string_lossy().to_string(), backup_path: None, - warnings: vec!["rolled back".into()], + warnings, errors: Vec::new(), }) } + +#[cfg(test)] +mod tests { + use super::history_page_from_snapshot_index; + use crate::history::{SnapshotIndex, SnapshotMeta}; + use crate::recipe_store::Artifact; + + #[test] + fn history_page_from_snapshot_index_preserves_run_id_and_artifacts() { + let page = history_page_from_snapshot_index(SnapshotIndex { + items: vec![SnapshotMeta { + id: "1710240000-clawpal-discord-channel-persona.json".into(), + recipe_id: Some("discord-channel-persona".into()), + created_at: "2026-03-12T00:00:00Z".into(), + config_path: "~/.clawpal/snapshots/1710240000-clawpal-discord-channel-persona.json" + .into(), + source: "clawpal".into(), + can_rollback: true, + run_id: Some("run_remote_01".into()), + rollback_of: None, + artifacts: vec![Artifact { + id: "artifact_01".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly.service".into(), + path: None, + }], + }], + }); + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].run_id.as_deref(), Some("run_remote_01")); + assert_eq!( + page.items[0].recipe_id.as_deref(), + Some("discord-channel-persona") + ); + assert_eq!(page.items[0].artifacts.len(), 1); + assert_eq!( + page.items[0].artifacts[0].label, + "clawpal-job-hourly.service" + ); + } +} diff --git a/src-tauri/src/commands/doctor_assistant.rs b/src-tauri/src/commands/doctor_assistant.rs index bac699e0..b5156537 100644 --- a/src-tauri/src/commands/doctor_assistant.rs +++ b/src-tauri/src/commands/doctor_assistant.rs @@ -5174,6 +5174,7 @@ mod tests { clawpal_dir: clawpal_dir.clone(), history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 137f8b7d..b5fbe21a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; @@ -25,6 +26,13 @@ use crate::openclaw_doc_resolver::{ resolve_local_doc_guidance, resolve_remote_doc_guidance, DocCitation, DocGuidance, DocResolveIssue, DocResolveRequest, RootCauseHypothesis, }; +use crate::recipe_executor::{ + execute_recipe as prepare_recipe_execution, ExecuteRecipeRequest, ExecuteRecipeResult, +}; +use crate::recipe_store::{ + Artifact as RecipeRuntimeArtifact, RecipeStore, ResourceClaim as RecipeRuntimeResourceClaim, + Run as RecipeRuntimeRun, +}; use crate::ssh::{SftpEntry, SshConnectionPool, SshExecResult, SshHostConfig, SshTransferStats}; use clawpal_core::ssh::diagnostic::{ from_any_error, SshDiagnosticReport, SshDiagnosticStatus, SshErrorCode, SshIntent, SshStage, @@ -93,9 +101,13 @@ fn shell_escape(s: &str) -> String { } use crate::recipe::{ - build_candidate_config_from_template, collect_change_paths, format_diff, - load_recipes_with_fallback, ApplyResult, PreviewResult, + build_candidate_config_from_template, collect_change_paths, find_recipe_with_source, + format_diff, load_recipes_from_source_text, load_recipes_with_fallback, validate_recipe_source, + ApplyResult, PreviewResult, RecipeSourceDiagnostics, }; +use crate::recipe_adapter::export_recipe_source as export_recipe_source_document; +use crate::recipe_planner::{build_recipe_plan, build_recipe_plan_from_source_text, RecipePlan}; +use crate::recipe_workspace::{RecipeSourceSaveResult, RecipeWorkspace, RecipeWorkspaceEntry}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -480,7 +492,11 @@ pub struct HistoryItem { pub source: String, pub can_rollback: bool, #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub rollback_of: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -878,15 +894,6 @@ mod parse_agents_cli_output_tests { } } -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") { - if let Some(home) = std::env::var("HOME").ok() { - return format!("{}{}", home, &path[1..]); - } - } - path.to_string() -} - fn analyze_sessions_sync() -> Result, String> { let paths = resolve_paths(); let agents_root = paths.base_dir.join("agents"); @@ -1233,6 +1240,1014 @@ pub fn list_recipes(source: Option) -> Result Ok(load_recipes_with_fallback(source, &default_path)) } +#[tauri::command] +pub fn list_recipes_from_source_text( + source_text: String, +) -> Result, String> { + load_recipes_from_source_text(&source_text) +} + +#[tauri::command] +pub fn validate_recipe_source_text(source_text: String) -> Result { + validate_recipe_source(&source_text) +} + +#[tauri::command] +pub fn list_recipe_workspace_entries() -> Result, String> { + RecipeWorkspace::from_resolved_paths().list_entries() +} + +#[tauri::command] +pub fn read_recipe_workspace_source(slug: String) -> Result { + RecipeWorkspace::from_resolved_paths().read_recipe_source(&slug) +} + +#[tauri::command] +pub fn save_recipe_workspace_source( + slug: String, + source: String, +) -> Result { + RecipeWorkspace::from_resolved_paths().save_recipe_source(&slug, &source) +} + +#[tauri::command] +pub fn delete_recipe_workspace_source(slug: String) -> Result { + RecipeWorkspace::from_resolved_paths().delete_recipe_source(&slug)?; + Ok(true) +} + +#[tauri::command] +pub fn export_recipe_source(recipe_id: String, source: Option) -> Result { + let recipe = find_recipe_with_source(&recipe_id, source) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + export_recipe_source_document(&recipe) +} + +#[tauri::command] +pub fn plan_recipe_source( + recipe_id: String, + params: Map, + source_text: String, +) -> Result { + build_recipe_plan_from_source_text(&recipe_id, ¶ms, &source_text) +} + +#[tauri::command] +pub fn plan_recipe( + recipe_id: String, + params: Map, + source: Option, +) -> Result { + let recipe = find_recipe_with_source(&recipe_id, source) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + build_recipe_plan(&recipe, ¶ms) +} + +#[tauri::command] +pub fn list_recipe_instances() -> Result, String> { + RecipeStore::from_resolved_paths().list_instances() +} + +#[tauri::command] +pub fn list_recipe_runs(instance_id: Option) -> Result, String> { + let store = RecipeStore::from_resolved_paths(); + match instance_id { + Some(instance_id) => store.list_runs(&instance_id), + None => store.list_all_runs(), + } +} + +fn build_runtime_claims( + spec: &crate::execution_spec::ExecutionSpec, +) -> Vec { + spec.resources + .claims + .iter() + .map(|claim| RecipeRuntimeResourceClaim { + kind: claim.kind.clone(), + id: claim.id.clone(), + target: claim.target.clone(), + path: claim.path.clone(), + }) + .collect() +} + +fn infer_recipe_id(spec: &crate::execution_spec::ExecutionSpec) -> String { + spec.source + .get("recipeId") + .and_then(Value::as_str) + .or_else(|| spec.metadata.name.as_deref()) + .unwrap_or("recipe") + .to_string() +} + +fn persist_recipe_run( + spec: &crate::execution_spec::ExecutionSpec, + prepared: &crate::recipe_executor::ExecuteRecipePrepared, + instance_id: &str, + status: &str, + summary: &str, + started_at: &str, + finished_at: &str, + warnings: &[String], +) -> Result<(), String> { + RecipeStore::from_resolved_paths() + .record_run(RecipeRuntimeRun { + id: prepared.run_id.clone(), + instance_id: instance_id.to_string(), + recipe_id: infer_recipe_id(spec), + execution_kind: prepared.plan.execution_kind.clone(), + runner: prepared.route.runner.clone(), + status: status.to_string(), + summary: summary.to_string(), + started_at: started_at.to_string(), + finished_at: Some(finished_at.to_string()), + artifacts: crate::recipe_executor::build_runtime_artifacts(spec, prepared), + resource_claims: build_runtime_claims(spec), + warnings: warnings.to_vec(), + source_origin: infer_recipe_source_origin(spec), + source_digest: infer_recipe_source_digest(spec), + workspace_path: infer_recipe_workspace_path(spec), + }) + .map(|_| ()) +} + +fn infer_recipe_source_origin(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeSourceOrigin") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn infer_recipe_source_digest(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeSourceDigest") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn infer_recipe_workspace_path(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeWorkspacePath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn find_recipe_run(run_id: &str) -> Result, String> { + RecipeStore::from_resolved_paths() + .list_all_runs() + .map(|runs| runs.into_iter().find(|run| run.id == run_id)) +} + +fn execute_local_cleanup_commands(commands: &[Vec]) -> Vec { + let mut warnings = Vec::new(); + for command in commands { + if command.is_empty() { + continue; + } + match Command::new(&command[0]).args(&command[1..]).output() { + Ok(output) if output.status.success() => {} + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + warnings.push(format!( + "Cleanup command failed ({}): {}", + command.join(" "), + detail + )); + } + Err(error) => warnings.push(format!( + "Cleanup command failed to start ({}): {}", + command.join(" "), + error + )), + } + } + warnings +} + +async fn execute_remote_cleanup_commands( + pool: &SshConnectionPool, + host_id: &str, + commands: &[Vec], +) -> Vec { + let mut warnings = Vec::new(); + for command in commands { + if command.is_empty() { + continue; + } + let shell_command = command + .iter() + .map(|part| shell_escape(part)) + .collect::>() + .join(" "); + match pool.exec(host_id, &shell_command).await { + Ok(output) if output.exit_code == 0 => {} + Ok(output) => { + let detail = if !output.stderr.trim().is_empty() { + output.stderr.trim().to_string() + } else { + output.stdout.trim().to_string() + }; + warnings.push(format!( + "Remote cleanup command failed ({}): {}", + command.join(" "), + detail + )); + } + Err(error) => warnings.push(format!( + "Remote cleanup command failed to start ({}): {}", + command.join(" "), + error + )), + } + } + warnings +} + +fn cleanup_local_recipe_artifacts(artifacts: &[RecipeRuntimeArtifact]) -> Vec { + let mut warnings = Vec::new(); + let mut removed_drop_in = false; + + for artifact in artifacts { + if artifact.kind != "systemdDropIn" { + continue; + } + let Some(path) = artifact.path.as_deref() else { + continue; + }; + let expanded = expand_home_path(path); + if !expanded.exists() { + continue; + } + match fs::remove_file(&expanded) { + Ok(()) => { + removed_drop_in = true; + } + Err(error) => warnings.push(format!( + "Failed to remove drop-in artifact {}: {}", + expanded.display(), + error + )), + } + } + + let mut commands = crate::recipe_executor::build_cleanup_commands(artifacts); + if removed_drop_in + && !commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + }) + { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + warnings.extend(execute_local_cleanup_commands(&commands)); + warnings +} + +async fn cleanup_remote_recipe_artifacts( + pool: &SshConnectionPool, + host_id: &str, + artifacts: &[RecipeRuntimeArtifact], +) -> Vec { + let mut warnings = Vec::new(); + let mut removed_drop_in = false; + + for artifact in artifacts { + if artifact.kind != "systemdDropIn" { + continue; + } + let Some(path) = artifact.path.as_deref() else { + continue; + }; + match pool.sftp_remove(host_id, path).await { + Ok(()) => { + removed_drop_in = true; + } + Err(error) if is_remote_missing_path_error(&error) => {} + Err(error) => warnings.push(format!( + "Failed to remove remote drop-in artifact {}: {}", + path, error + )), + } + } + + let mut commands = crate::recipe_executor::build_cleanup_commands(artifacts); + if removed_drop_in + && !commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + }) + { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + warnings.extend(execute_remote_cleanup_commands(pool, host_id, &commands).await); + warnings +} + +fn cleanup_local_recipe_snapshot(snapshot: &crate::history::SnapshotMeta) -> Vec { + if let Some(run_id) = snapshot.run_id.as_deref() { + match find_recipe_run(run_id) { + Ok(Some(run)) => return cleanup_local_recipe_artifacts(&run.artifacts), + Ok(None) if !snapshot.artifacts.is_empty() => {} + Ok(None) => { + return vec![format!( + "No recipe runtime run found for rollback runId {}", + run_id + )]; + } + Err(error) if !snapshot.artifacts.is_empty() => {} + Err(error) => { + return vec![format!( + "Failed to load recipe runtime run {} for rollback: {}", + run_id, error + )]; + } + } + } + cleanup_local_recipe_artifacts(&snapshot.artifacts) +} + +async fn cleanup_remote_recipe_snapshot( + pool: &SshConnectionPool, + host_id: &str, + snapshot: &crate::history::SnapshotMeta, +) -> Vec { + if let Some(run_id) = snapshot.run_id.as_deref() { + match find_recipe_run(run_id) { + Ok(Some(run)) => { + return cleanup_remote_recipe_artifacts(pool, host_id, &run.artifacts).await + } + Ok(None) if !snapshot.artifacts.is_empty() => {} + Ok(None) => { + return vec![format!( + "No recipe runtime run found for rollback runId {}", + run_id + )]; + } + Err(error) if !snapshot.artifacts.is_empty() => {} + Err(error) => { + return vec![format!( + "Failed to load recipe runtime run {} for rollback: {}", + run_id, error + )]; + } + } + } + cleanup_remote_recipe_artifacts(pool, host_id, &snapshot.artifacts).await +} + +pub(crate) const INTERNAL_SETUP_IDENTITY_COMMAND: &str = "__setup_identity__"; +pub(crate) const INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND: &str = "__systemd_dropin_write__"; + +fn action_string(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + }) +} + +fn action_bool(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(value)) => *value, + Some(Value::String(value)) => value.trim().eq_ignore_ascii_case("true"), + _ => false, + } +} + +fn recipe_action_setup_identity_command( + agent_id: &str, + name: &str, + emoji: Option<&str>, +) -> (String, Vec) { + let mut command = vec![ + INTERNAL_SETUP_IDENTITY_COMMAND.to_string(), + agent_id.to_string(), + name.to_string(), + ]; + if let Some(emoji) = emoji.map(str::trim).filter(|value| !value.is_empty()) { + command.push(emoji.to_string()); + } + (format!("Setup identity: {}", agent_id), command) +} + +fn append_config_patch_commands( + value: &Value, + path: &str, + commands: &mut Vec<(String, Vec)>, +) -> Result<(), String> { + match value { + Value::Object(map) => { + for (key, nested) in map { + let next_path = if path.is_empty() { + key.clone() + } else { + format!("{}.{}", path, key) + }; + append_config_patch_commands(nested, &next_path, commands)?; + } + Ok(()) + } + _ => { + let full_path = if path.is_empty() { + ".".to_string() + } else { + path.to_string() + }; + let json_value = serde_json::to_string(value).map_err(|error| error.to_string())?; + commands.push(( + format!("Set {}", full_path), + vec![ + "openclaw".into(), + "config".into(), + "set".into(), + full_path, + json_value, + "--json".into(), + ], + )); + Ok(()) + } + } +} + +fn rewrite_binding_entries( + bindings: Vec, + channel_type: &str, + peer_id: &str, + agent_id: &str, +) -> Vec { + let mut next: Vec = bindings + .into_iter() + .filter(|binding| { + let Some(matcher) = binding.get("match").and_then(Value::as_object) else { + return true; + }; + let Some(channel) = matcher.get("channel").and_then(Value::as_str) else { + return true; + }; + let Some(peer) = matcher.get("peer").and_then(Value::as_object) else { + return true; + }; + let Some(existing_peer_id) = peer.get("id").and_then(Value::as_str) else { + return true; + }; + !(channel == channel_type && existing_peer_id == peer_id) + }) + .collect(); + + next.push(json!({ + "agentId": agent_id, + "match": { + "channel": channel_type, + "peer": { + "kind": "channel", + "id": peer_id, + } + } + })); + next +} + +async fn resolve_model_value_for_route( + pool: &State<'_, SshConnectionPool>, + route: &crate::recipe_executor::ExecutionRoute, + profile_id: Option<&str>, +) -> Result, String> { + let Some(profile_id) = profile_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if profile_id == "__default__" { + return Ok(None); + } + + let profiles = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_model_profiles(pool.clone(), host_id).await? + } + _ => list_model_profiles()?, + }; + + Ok(profiles + .iter() + .find(|profile| profile.id == profile_id) + .map(profile_to_model_value) + .or_else(|| Some(profile_id.to_string()))) +} + +async fn resolve_workspace_for_route( + cache: &State<'_, crate::cli_runner::CliCache>, + pool: &State<'_, SshConnectionPool>, + route: &crate::recipe_executor::ExecutionRoute, + independent: bool, + agent_id: &str, +) -> Result, String> { + if independent { + return Ok(Some(agent_id.to_string())); + } + + match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + let (_, _, cfg) = remote_read_openclaw_config_text_and_json(pool, &host_id).await?; + if let Some(workspace) = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(Some(workspace.to_string())); + } + + Ok(remote_list_agents_overview(pool.clone(), host_id) + .await? + .into_iter() + .find_map(|agent| agent.workspace.filter(|value| !value.trim().is_empty()))) + } + _ => { + let paths = resolve_paths(); + let cfg = read_openclaw_config(&paths)?; + if let Some(workspace) = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(Some(workspace.to_string())); + } + + Ok(list_agents_overview(cache.clone()) + .await? + .into_iter() + .find_map(|agent| agent.workspace.filter(|value| !value.trim().is_empty()))) + } + } +} + +async fn list_bindings_for_route( + cache: &State<'_, crate::cli_runner::CliCache>, + pool: &State<'_, SshConnectionPool>, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result, String> { + match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_bindings(pool.clone(), host_id).await + } + _ => list_bindings(cache.clone()).await, + } +} + +async fn materialize_recipe_action_commands( + action: &crate::execution_spec::ExecutionAction, + cache: &State<'_, crate::cli_runner::CliCache>, + pool: &State<'_, SshConnectionPool>, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result)>, String> { + let kind = action + .kind + .as_deref() + .ok_or_else(|| "legacy action is missing kind".to_string())?; + let args = action + .args + .as_object() + .ok_or_else(|| format!("legacy action '{}' is missing object args", kind))?; + + match kind { + "create_agent" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "create_agent requires agentId".to_string())?; + let independent = action_bool(args.get("independent")); + let model_profile_id = action_string(args.get("modelProfileId")); + let model_value = + resolve_model_value_for_route(pool, route, model_profile_id.as_deref()).await?; + let workspace = + resolve_workspace_for_route(cache, pool, route, independent, &agent_id).await?; + + let mut command = vec![ + "openclaw".into(), + "agents".into(), + "add".into(), + agent_id.clone(), + "--non-interactive".into(), + ]; + if let Some(model_value) = model_value { + command.push("--model".into()); + command.push(model_value); + } + if let Some(workspace) = workspace { + command.push("--workspace".into()); + command.push(workspace); + } + + Ok(vec![(format!("Create agent: {}", agent_id), command)]) + } + "setup_identity" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "setup_identity requires agentId".to_string())?; + let name = action_string(args.get("name")) + .ok_or_else(|| "setup_identity requires name".to_string())?; + let emoji = action_string(args.get("emoji")); + Ok(vec![recipe_action_setup_identity_command( + &agent_id, + &name, + emoji.as_deref(), + )]) + } + "bind_channel" => { + let channel_type = action_string(args.get("channelType")) + .ok_or_else(|| "bind_channel requires channelType".to_string())?; + let peer_id = action_string(args.get("peerId")) + .ok_or_else(|| "bind_channel requires peerId".to_string())?; + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "bind_channel requires agentId".to_string())?; + let bindings = list_bindings_for_route(cache, pool, route).await?; + let payload = rewrite_binding_entries(bindings, &channel_type, &peer_id, &agent_id); + let payload_json = + serde_json::to_string(&payload).map_err(|error| error.to_string())?; + + Ok(vec![( + format!("Bind {}:{} -> {}", channel_type, peer_id, agent_id), + vec![ + "openclaw".into(), + "config".into(), + "set".into(), + "bindings".into(), + payload_json, + "--json".into(), + ], + )]) + } + "config_patch" => { + let patch = if let Some(patch) = args.get("patch") { + patch.clone() + } else if let Some(template) = action_string(args.get("patchTemplate")) { + json5::from_str::(&template).map_err(|error| error.to_string())? + } else { + return Err("config_patch requires patch or patchTemplate".into()); + }; + + let mut commands = Vec::new(); + append_config_patch_commands(&patch, "", &mut commands)?; + Ok(commands) + } + other => Err(format!("unsupported recipe action '{}'", other)), + } +} + +async fn materialize_recipe_commands( + spec: &crate::execution_spec::ExecutionSpec, + cache: &State<'_, crate::cli_runner::CliCache>, + pool: &State<'_, SshConnectionPool>, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result)>, String> { + let mut commands = Vec::new(); + for action in &spec.actions { + commands.extend(materialize_recipe_action_commands(action, cache, pool, route).await?); + } + Ok(commands) +} + +#[cfg(test)] +mod recipe_action_materializer_tests { + use super::{recipe_action_setup_identity_command, INTERNAL_SETUP_IDENTITY_COMMAND}; + + #[test] + fn setup_identity_materializes_to_internal_command() { + let (label, command) = + recipe_action_setup_identity_command("lobster", "Lobster", Some("🦞")); + + assert_eq!(label, "Setup identity: lobster"); + assert_eq!( + command, + vec![ + INTERNAL_SETUP_IDENTITY_COMMAND.to_string(), + "lobster".into(), + "Lobster".into(), + "🦞".into(), + ] + ); + } +} + +#[cfg(test)] +mod runtime_artifact_tests { + use crate::execution_spec::{ + ExecutionAction, ExecutionCapabilities, ExecutionMetadata, ExecutionResourceClaim, + ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, + }; + use crate::recipe_executor::{ + build_runtime_artifacts, execute_recipe as prepare_recipe_execution, ExecuteRecipeRequest, + }; + use serde_json::json; + + fn sample_schedule_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-reconcile".into()), + digest: None, + }, + source: serde_json::Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "schedule".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("schedule/hourly".into()), + target: Some("job/hourly-reconcile".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "schedule": { + "id": "schedule/hourly", + "onCalendar": "hourly", + }, + "job": { + "command": ["openclaw", "doctor", "run"], + } + }), + actions: vec![ExecutionAction { + kind: Some("schedule".into()), + name: Some("Run hourly reconcile".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + "onCalendar": "hourly", + }), + }], + outputs: vec![], + } + } + + #[test] + fn build_runtime_artifacts_tracks_schedule_timer_units() { + let spec = sample_schedule_spec(); + let prepared = prepare_recipe_execution(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare recipe execution"); + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdUnit")); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdTimer")); + } +} + +#[tauri::command] +pub async fn execute_recipe( + queue: State<'_, crate::cli_runner::CommandQueue>, + cache: State<'_, crate::cli_runner::CliCache>, + pool: State<'_, SshConnectionPool>, + remote_queues: State<'_, crate::cli_runner::RemoteCommandQueues>, + mut request: ExecuteRecipeRequest, +) -> Result { + let mut source = request.spec.source.as_object().cloned().unwrap_or_default(); + + if let Some(source_origin) = request + .source_origin + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + source.insert( + "recipeSourceOrigin".into(), + Value::String(source_origin.to_string()), + ); + } + + if let Some(source_text) = request + .source_text + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + source.insert( + "recipeSourceDigest".into(), + Value::String( + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, source_text.as_bytes()).to_string(), + ), + ); + } + + if let Some(workspace_slug) = request + .workspace_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Ok(path) = + RecipeWorkspace::from_resolved_paths().resolve_recipe_source_path(workspace_slug) + { + source.insert("recipeWorkspacePath".into(), Value::String(path)); + } + } + + if !source.is_empty() { + request.spec.source = Value::Object(source); + } + let spec = request.spec.clone(); + let prepared = prepare_recipe_execution(request)?; + let mut warnings = prepared.warnings.clone(); + let started_at = Utc::now().to_rfc3339(); + let summary = prepared.summary.clone(); + let runtime_artifacts = crate::recipe_executor::build_runtime_artifacts(&spec, &prepared); + + match prepared.route.runner.as_str() { + "local" => { + if !prepared.plan.commands.is_empty() { + crate::cli_runner::enqueue_materialized_plan(queue.inner(), &prepared.plan); + } else { + let commands = + materialize_recipe_commands(&spec, &cache, &pool, &prepared.route).await?; + if commands.is_empty() { + return Err("recipe did not materialize executable commands".into()); + } + for (label, command) in commands { + queue.inner().enqueue(label, command); + } + } + let result = crate::cli_runner::apply_queued_commands( + queue, + cache, + Some(infer_recipe_id(&spec)), + Some(prepared.run_id.clone()), + Some(runtime_artifacts.clone()), + ) + .await?; + let finished_at = Utc::now().to_rfc3339(); + if !result.ok { + let error = result + .error + .unwrap_or_else(|| "recipe execution failed".to_string()); + warnings.extend(cleanup_local_recipe_artifacts(&runtime_artifacts)); + let _ = persist_recipe_run( + &spec, + &prepared, + "local", + "failed", + &error, + &started_at, + &finished_at, + &warnings, + ); + return Err(error); + } + + if let Err(error) = persist_recipe_run( + &spec, + &prepared, + "local", + "succeeded", + &summary, + &started_at, + &finished_at, + &warnings, + ) { + warnings.push(format!("Failed to persist recipe runtime state: {}", error)); + } + + Ok(ExecuteRecipeResult { + run_id: prepared.run_id, + instance_id: "local".into(), + summary, + warnings, + }) + } + "remote_ssh" => { + let host_id = prepared + .route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + if !prepared.plan.commands.is_empty() { + crate::cli_runner::enqueue_materialized_plan_remote( + remote_queues.inner(), + &host_id, + &prepared.plan, + ); + } else { + let commands = + materialize_recipe_commands(&spec, &cache, &pool, &prepared.route).await?; + if commands.is_empty() { + return Err("recipe did not materialize executable commands".into()); + } + for (label, command) in commands { + remote_queues.inner().enqueue(&host_id, label, command); + } + } + let result = crate::cli_runner::remote_apply_queued_commands( + pool.clone(), + remote_queues, + host_id.clone(), + Some(infer_recipe_id(&spec)), + Some(prepared.run_id.clone()), + Some(runtime_artifacts.clone()), + ) + .await?; + let finished_at = Utc::now().to_rfc3339(); + if !result.ok { + let error = result + .error + .unwrap_or_else(|| "remote recipe execution failed".to_string()); + warnings.extend( + cleanup_remote_recipe_artifacts(&pool, &host_id, &runtime_artifacts).await, + ); + let _ = persist_recipe_run( + &spec, + &prepared, + &host_id, + "failed", + &error, + &started_at, + &finished_at, + &warnings, + ); + return Err(error); + } + + if let Err(error) = persist_recipe_run( + &spec, + &prepared, + &host_id, + "succeeded", + &summary, + &started_at, + &finished_at, + &warnings, + ) { + warnings.push(format!("Failed to persist recipe runtime state: {}", error)); + } + + Ok(ExecuteRecipeResult { + run_id: prepared.run_id, + instance_id: host_id, + summary, + warnings, + }) + } + other => { + warnings.push(format!("route '{}' is not executable yet", other)); + Err(format!("unsupported execution runner: {}", other)) + } + } +} + #[tauri::command] pub async fn manage_rescue_bot( action: String, @@ -6063,6 +7078,8 @@ fn write_config_with_snapshot( true, current_text, None, + None, + Vec::new(), )?; write_json(&paths.config_path, next) } @@ -7393,6 +8410,7 @@ mod model_profile_upsert_tests { base_dir, history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), clawpal_dir, } } @@ -9742,7 +10760,7 @@ async fn remote_resolve_openclaw_config_path( Ok(path.to_string()) } -async fn remote_read_openclaw_config_text_and_json( +pub(crate) async fn remote_read_openclaw_config_text_and_json( pool: &SshConnectionPool, host_id: &str, ) -> Result<(String, String, Value), String> { diff --git a/src-tauri/src/commands/precheck.rs b/src-tauri/src/commands/precheck.rs index f5cbafa4..d355ebd6 100644 --- a/src-tauri/src/commands/precheck.rs +++ b/src-tauri/src/commands/precheck.rs @@ -3,6 +3,42 @@ use tauri::State; use crate::ssh::SshConnectionPool; +fn merge_auth_precheck_issues( + profiles: &[clawpal_core::profile::ModelProfile], + resolved_keys: &[super::ResolvedApiKey], +) -> Vec { + let mut issues = precheck::precheck_auth(profiles); + for profile in profiles { + if !profile.enabled { + continue; + } + if profile.provider.trim().is_empty() || profile.model.trim().is_empty() { + continue; + } + if super::provider_supports_optional_api_key(&profile.provider) { + continue; + } + + let resolved = resolved_keys + .iter() + .find(|item| item.profile_id == profile.id); + if resolved.is_some_and(|item| item.resolved) { + continue; + } + + issues.push(PrecheckIssue { + code: "AUTH_CREDENTIAL_UNRESOLVED".into(), + severity: "error".into(), + message: format!( + "Profile '{}' has no resolved credential for provider '{}'", + profile.id, profile.provider + ), + auto_fixable: false, + }); + } + issues +} + #[tauri::command] pub async fn precheck_registry() -> Result, String> { let registry_path = clawpal_core::instance::registry_path(); @@ -69,9 +105,91 @@ pub async fn precheck_transport( } #[tauri::command] -pub async fn precheck_auth(instance_id: String) -> Result, String> { - let openclaw = clawpal_core::openclaw::OpenclawCli::new(); - let profiles = clawpal_core::profile::list_profiles(&openclaw).map_err(|e| e.to_string())?; - let _ = instance_id; // reserved for future per-instance profile filtering - Ok(precheck::precheck_auth(&profiles)) +pub async fn precheck_auth( + pool: State<'_, SshConnectionPool>, + instance_id: String, +) -> Result, String> { + let registry = clawpal_core::instance::InstanceRegistry::load().map_err(|e| e.to_string())?; + let instance = registry + .get(&instance_id) + .ok_or_else(|| format!("Instance not found: {instance_id}"))?; + + match &instance.instance_type { + clawpal_core::instance::InstanceType::RemoteSsh => { + let (profiles, _) = + super::profiles::collect_remote_profiles_from_openclaw(&pool, &instance_id, true) + .await?; + let resolved = super::profiles::resolve_remote_api_keys_for_profiles( + &pool, + &instance_id, + &profiles, + ) + .await; + Ok(merge_auth_precheck_issues(&profiles, &resolved)) + } + _ => { + let openclaw = clawpal_core::openclaw::OpenclawCli::new(); + let profiles = + clawpal_core::profile::list_profiles(&openclaw).map_err(|e| e.to_string())?; + let resolved = super::resolve_api_keys()?; + Ok(merge_auth_precheck_issues(&profiles, &resolved)) + } + } +} + +#[cfg(test)] +mod tests { + use super::merge_auth_precheck_issues; + use crate::commands::{ResolvedApiKey, ResolvedCredentialKind}; + use clawpal_core::profile::ModelProfile; + + fn profile(id: &str, provider: &str, model: &str) -> ModelProfile { + ModelProfile { + id: id.into(), + name: format!("{provider}/{model}"), + provider: provider.into(), + model: model.into(), + auth_ref: "OPENAI_API_KEY".into(), + api_key: None, + base_url: None, + description: None, + enabled: true, + } + } + + #[test] + fn auth_precheck_detects_unresolved_required_credentials() { + let issues = merge_auth_precheck_issues( + &[profile("p1", "openai", "gpt-4o")], + &[ResolvedApiKey { + profile_id: "p1".into(), + masked_key: "not set".into(), + credential_kind: ResolvedCredentialKind::Unset, + auth_ref: Some("OPENAI_API_KEY".into()), + resolved: false, + }], + ); + + assert!(issues + .iter() + .any(|issue| issue.code == "AUTH_CREDENTIAL_UNRESOLVED")); + } + + #[test] + fn auth_precheck_skips_optional_api_key_providers() { + let issues = merge_auth_precheck_issues( + &[profile("p1", "ollama", "llama3")], + &[ResolvedApiKey { + profile_id: "p1".into(), + masked_key: "not set".into(), + credential_kind: ResolvedCredentialKind::Unset, + auth_ref: None, + resolved: false, + }], + ); + + assert!(!issues + .iter() + .any(|issue| issue.code == "AUTH_CREDENTIAL_UNRESOLVED")); + } } diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 150fb15d..8028dbaf 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -179,6 +179,7 @@ mod tests { clawpal_dir: clawpal_dir.clone(), history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), }, root, ) diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 4d2d5a43..9934b329 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -385,7 +385,7 @@ async fn read_remote_profiles_storage_text( } } -async fn collect_remote_profiles_from_openclaw( +pub(super) async fn collect_remote_profiles_from_openclaw( pool: &SshConnectionPool, host_id: &str, persist_storage: bool, @@ -410,6 +410,43 @@ async fn collect_remote_profiles_from_openclaw( Ok((next_profiles, result)) } +pub(super) async fn resolve_remote_api_keys_for_profiles( + pool: &SshConnectionPool, + host_id: &str, + profiles: &[ModelProfile], +) -> Vec { + let auth_cache = RemoteAuthCache::build(pool, host_id, profiles).await.ok(); + + let mut out = Vec::new(); + for profile in profiles { + let (resolved_key, source) = if let Some(ref cache) = auth_cache { + if let Some((key, source)) = cache.resolve_for_profile_with_source(profile) { + (key, Some(source)) + } else { + (String::new(), None) + } + } else { + match resolve_remote_profile_api_key(pool, host_id, profile).await { + Ok(key) => (key, None), + Err(_) => (String::new(), None), + } + }; + let resolved_override = if resolved_key.trim().is_empty() && oauth_session_ready(profile) { + Some(true) + } else { + None + }; + out.push(build_resolved_api_key( + profile, + &resolved_key, + source, + resolved_override, + )); + } + + out +} + #[tauri::command] pub async fn remote_list_model_profiles( pool: State<'_, SshConnectionPool>, @@ -466,37 +503,7 @@ pub async fn remote_resolve_api_keys( host_id: String, ) -> Result, String> { let (profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?; - let auth_cache = RemoteAuthCache::build(&pool, &host_id, &profiles) - .await - .ok(); - - let mut out = Vec::new(); - for profile in &profiles { - let (resolved_key, source) = if let Some(ref cache) = auth_cache { - if let Some((key, source)) = cache.resolve_for_profile_with_source(profile) { - (key, Some(source)) - } else { - (String::new(), None) - } - } else { - match resolve_remote_profile_api_key(&pool, &host_id, profile).await { - Ok(key) => (key, None), - Err(_) => (String::new(), None), - } - }; - let resolved_override = if resolved_key.trim().is_empty() && oauth_session_ready(profile) { - Some(true) - } else { - None - }; - out.push(build_resolved_api_key( - profile, - &resolved_key, - source, - resolved_override, - )); - } - Ok(out) + Ok(resolve_remote_api_keys_for_profiles(&pool, &host_id, &profiles).await) } #[tauri::command] diff --git a/src-tauri/src/execution_spec.rs b/src-tauri/src/execution_spec.rs new file mode 100644 index 00000000..5e2919a5 --- /dev/null +++ b/src-tauri/src/execution_spec.rs @@ -0,0 +1,178 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +use crate::recipe_bundle::{parse_structured_document, validate_execution_kind, RecipeBundle}; + +const SUPPORTED_RESOURCE_CLAIM_KINDS: &[&str] = + &["path", "file", "service", "channel", "agent", "identity"]; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionMetadata { + pub name: Option, + pub digest: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionTarget { + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionCapabilities { + pub used_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionResourceClaim { + pub kind: String, + pub id: Option, + pub target: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionResources { + pub claims: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSecretBinding { + pub id: String, + pub source: String, + pub mount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSecrets { + pub bindings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionAction { + pub kind: Option, + pub name: Option, + pub args: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSpec { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + pub metadata: ExecutionMetadata, + pub source: Value, + pub target: Value, + pub execution: ExecutionTarget, + pub capabilities: ExecutionCapabilities, + pub resources: ExecutionResources, + pub secrets: ExecutionSecrets, + pub desired_state: Value, + pub actions: Vec, + pub outputs: Vec, +} + +pub fn parse_execution_spec(raw: &str) -> Result { + let spec: ExecutionSpec = parse_structured_document(raw)?; + validate_execution_spec(&spec)?; + Ok(spec) +} + +pub fn validate_execution_spec(spec: &ExecutionSpec) -> Result<(), String> { + if spec.kind != "ExecutionSpec" { + return Err(format!("unsupported document kind: {}", spec.kind)); + } + + validate_execution_kind(&spec.execution.kind)?; + + for claim in &spec.resources.claims { + if !SUPPORTED_RESOURCE_CLAIM_KINDS.contains(&claim.kind.as_str()) { + return Err(format!( + "resource claim '{}' uses an unsupported kind", + claim.kind + )); + } + } + + for binding in &spec.secrets.bindings { + if binding.source.trim().starts_with("plain://") { + return Err(format!( + "secret binding '{}' uses a disallowed plain source", + binding.id + )); + } + } + + Ok(()) +} + +pub fn validate_execution_spec_against_bundle( + spec: &ExecutionSpec, + bundle: &RecipeBundle, +) -> Result<(), String> { + validate_execution_spec(spec)?; + + if !bundle.execution.supported_kinds.is_empty() + && !bundle + .execution + .supported_kinds + .iter() + .any(|kind| kind == &spec.execution.kind) + { + return Err(format!( + "execution kind '{}' is not supported by this bundle", + spec.execution.kind + )); + } + + let allowed_capabilities: BTreeSet<&str> = bundle + .capabilities + .allowed + .iter() + .map(String::as_str) + .collect(); + let unsupported_capabilities: Vec<&str> = spec + .capabilities + .used_capabilities + .iter() + .map(String::as_str) + .filter(|capability| !allowed_capabilities.contains(capability)) + .collect(); + if !unsupported_capabilities.is_empty() { + return Err(format!( + "execution spec uses capabilities not granted by bundle: {}", + unsupported_capabilities.join(", ") + )); + } + + let supported_resource_kinds: BTreeSet<&str> = bundle + .resources + .supported_kinds + .iter() + .map(String::as_str) + .collect(); + let unsupported_claims: Vec<&str> = spec + .resources + .claims + .iter() + .map(|claim| claim.kind.as_str()) + .filter(|kind| !supported_resource_kinds.contains(kind)) + .collect(); + if !unsupported_claims.is_empty() { + return Err(format!( + "execution spec declares claims for unsupported resource kinds: {}", + unsupported_claims.join(", ") + )); + } + + Ok(()) +} diff --git a/src-tauri/src/execution_spec_tests.rs b/src-tauri/src/execution_spec_tests.rs new file mode 100644 index 00000000..3d2a4ec5 --- /dev/null +++ b/src-tauri/src/execution_spec_tests.rs @@ -0,0 +1,64 @@ +use crate::execution_spec::parse_execution_spec; +use crate::recipe_bundle::{parse_recipe_bundle, validate_execution_spec_against_bundle}; + +#[test] +fn execution_spec_rejects_inline_secret_value() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: job } +secrets: { bindings: [{ id: "k", source: "plain://abc" }] }"#; + + assert!(parse_execution_spec(raw).is_err()); +} + +#[test] +fn execution_spec_rejects_capabilities_outside_bundle_budget() { + let bundle_raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +capabilities: { allowed: ["service.manage"] } +resources: { supportedKinds: ["path"] } +execution: { supportedKinds: ["job"] }"#; + let spec_raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: "job" } +capabilities: { usedCapabilities: ["service.manage", "secret.read"] } +resources: { claims: [{ kind: "path", path: "/tmp/openclaw" }] }"#; + + let bundle = parse_recipe_bundle(bundle_raw).expect("parse bundle"); + let spec = parse_execution_spec(spec_raw).expect("parse spec"); + + assert!(validate_execution_spec_against_bundle(&bundle, &spec).is_err()); +} + +#[test] +fn execution_spec_rejects_unknown_resource_claim_kind() { + let bundle_raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +capabilities: { allowed: ["service.manage"] } +resources: { supportedKinds: ["path"] } +execution: { supportedKinds: ["job"] }"#; + let spec_raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: "job" } +capabilities: { usedCapabilities: ["service.manage"] } +resources: { claims: [{ kind: "file", path: "/tmp/app.sock" }] }"#; + + let bundle = parse_recipe_bundle(bundle_raw).expect("parse bundle"); + let spec = parse_execution_spec(spec_raw).expect("parse spec"); + + assert!(validate_execution_spec_against_bundle(&bundle, &spec).is_err()); +} + +#[test] +fn execution_spec_rejects_unknown_resource_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: + kind: job +resources: + claims: + - id: workspace + kind: workflow"#; + + assert!(parse_execution_spec(raw).is_err()); +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index da443df2..7b854b46 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -16,7 +16,11 @@ pub struct SnapshotMeta { pub source: String, pub can_rollback: bool, #[serde(skip_serializing_if = "Option::is_none", default)] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] pub rollback_of: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, } #[derive(Debug, Serialize, Deserialize, Default)] @@ -24,6 +28,30 @@ pub struct SnapshotIndex { pub items: Vec, } +pub fn parse_snapshot_index_text(text: &str) -> Result { + if text.trim().is_empty() { + return Ok(SnapshotIndex::default()); + } + serde_json::from_str(text).map_err(|e| e.to_string()) +} + +pub fn render_snapshot_index_text(index: &SnapshotIndex) -> Result { + serde_json::to_string_pretty(index).map_err(|e| e.to_string()) +} + +pub fn upsert_snapshot(index: &mut SnapshotIndex, snapshot: SnapshotMeta) { + index.items.retain(|existing| existing.id != snapshot.id); + index.items.push(snapshot); + index.items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + if index.items.len() > 200 { + index.items.truncate(200); + } +} + +pub fn find_snapshot<'a>(index: &'a SnapshotIndex, snapshot_id: &str) -> Option<&'a SnapshotMeta> { + index.items.iter().find(|item| item.id == snapshot_id) +} + pub fn list_snapshots(path: &std::path::Path) -> Result { if !path.exists() { return Ok(SnapshotIndex { items: Vec::new() }); @@ -31,10 +59,7 @@ pub fn list_snapshots(path: &std::path::Path) -> Result { let mut file = File::open(path).map_err(|e| e.to_string())?; let mut text = String::new(); file.read_to_string(&mut text).map_err(|e| e.to_string())?; - if text.trim().is_empty() { - return Ok(SnapshotIndex { items: Vec::new() }); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) + parse_snapshot_index_text(&text) } pub fn write_snapshots(path: &std::path::Path, index: &SnapshotIndex) -> Result<(), String> { @@ -42,7 +67,7 @@ pub fn write_snapshots(path: &std::path::Path, index: &SnapshotIndex) -> Result< .parent() .ok_or_else(|| "invalid metadata path".to_string())?; fs::create_dir_all(parent).map_err(|e| e.to_string())?; - let text = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?; + let text = render_snapshot_index_text(index)?; // Atomic write: write to .tmp file, sync, then rename let tmp = path.with_extension("tmp"); { @@ -60,7 +85,9 @@ pub fn add_snapshot( source: &str, rollbackable: bool, current_config: &str, + run_id: Option, rollback_of: Option, + artifacts: Vec, ) -> Result { fs::create_dir_all(paths).map_err(|e| e.to_string())?; @@ -80,19 +107,20 @@ pub fn add_snapshot( fs::write(&snapshot_path, current_config).map_err(|e| e.to_string())?; let mut next = index; - next.items.push(SnapshotMeta { - id: id.clone(), - recipe_id, - created_at: ts.clone(), - config_path: snapshot_path.to_string_lossy().to_string(), - source: source.to_string(), - can_rollback: rollbackable, - rollback_of: rollback_of.clone(), - }); - next.items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - if next.items.len() > 200 { - next.items.truncate(200); - } + upsert_snapshot( + &mut next, + SnapshotMeta { + id: id.clone(), + recipe_id, + created_at: ts.clone(), + config_path: snapshot_path.to_string_lossy().to_string(), + source: source.to_string(), + can_rollback: rollbackable, + run_id: run_id.clone(), + rollback_of: rollback_of.clone(), + artifacts: artifacts.clone(), + }, + ); write_snapshots(metadata_path, &next)?; let returned = Some(snapshot_recipe_id.clone()); @@ -104,7 +132,9 @@ pub fn add_snapshot( config_path: snapshot_path.to_string_lossy().to_string(), source: source.to_string(), can_rollback: rollbackable, + run_id, rollback_of, + artifacts, }) } @@ -120,8 +150,9 @@ pub fn read_snapshot(path: &str) -> Result { #[cfg(test)] mod tests { - use super::read_snapshot; + use super::{add_snapshot, list_snapshots, read_snapshot}; use crate::cli_runner::set_active_clawpal_data_override; + use crate::recipe_store::Artifact; use std::fs; use uuid::Uuid; @@ -141,4 +172,44 @@ mod tests { assert_eq!(result.expect("read snapshot"), "{\"ok\":true}"); let _ = fs::remove_dir_all(temp_root); } + + #[test] + fn add_snapshot_persists_run_id_and_artifacts_in_metadata() { + let temp_root = std::env::temp_dir().join(format!("clawpal-history-{}", Uuid::new_v4())); + let history_dir = temp_root.join("history"); + let metadata_path = temp_root.join("metadata.json"); + + let snapshot = add_snapshot( + &history_dir, + &metadata_path, + Some("discord-channel-persona".into()), + "clawpal", + true, + "{\"ok\":true}", + Some("run_01".into()), + None, + vec![Artifact { + id: "artifact_01".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly.service".into(), + path: None, + }], + ) + .expect("write snapshot metadata"); + let index = list_snapshots(&metadata_path).expect("read snapshot metadata"); + + assert_eq!(snapshot.run_id.as_deref(), Some("run_01")); + assert_eq!( + index.items.first().and_then(|item| item.run_id.as_deref()), + Some("run_01") + ); + assert_eq!(snapshot.artifacts.len(), 1); + assert_eq!(snapshot.artifacts[0].label, "clawpal-job-hourly.service"); + assert_eq!( + index.items.first().map(|item| item.artifacts.len()), + Some(1) + ); + + let _ = fs::remove_dir_all(temp_root); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0491a7c..4b97a48b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,28 +12,31 @@ use crate::commands::{ check_openclaw_update, clear_all_sessions, clear_session_model_override, connect_docker_instance, connect_local_instance, connect_ssh_instance, create_agent, delete_agent, delete_backup, delete_cron_job, delete_local_instance_home, delete_model_profile, - delete_registered_instance, delete_sessions_by_ids, delete_ssh_host, deploy_watchdog, - diagnose_doctor_assistant, diagnose_primary_via_rescue, diagnose_ssh, discover_local_instances, - ensure_access_profile, extract_model_profiles_from_config, fix_issues, get_app_preferences, + delete_recipe_workspace_source, delete_registered_instance, delete_sessions_by_ids, + delete_ssh_host, deploy_watchdog, diagnose_doctor_assistant, diagnose_primary_via_rescue, + diagnose_ssh, discover_local_instances, ensure_access_profile, execute_recipe, + export_recipe_source, extract_model_profiles_from_config, fix_issues, get_app_preferences, get_bug_report_settings, get_cached_model_catalog, get_channels_config_snapshot, get_channels_runtime_snapshot, get_cron_config_snapshot, get_cron_runs, get_cron_runtime_snapshot, get_instance_config_snapshot, get_instance_runtime_snapshot, get_rescue_bot_status, get_session_model_override, get_ssh_transfer_stats, get_status_extra, get_status_light, get_system_status, get_watchdog_status, list_agents_overview, list_backups, list_bindings, list_channels_minimal, list_cron_jobs, list_discord_guild_channels, - list_history, list_model_profiles, list_recipes, list_registered_instances, list_session_files, - list_ssh_config_hosts, list_ssh_hosts, local_openclaw_cli_available, - local_openclaw_config_exists, log_app_event, manage_rescue_bot, migrate_legacy_instances, - open_url, precheck_auth, precheck_instance, precheck_registry, precheck_transport, - preview_rollback, preview_session, probe_ssh_connection_profile, - push_model_profiles_to_local_openclaw, push_model_profiles_to_remote_openclaw, - push_related_secrets_to_remote, read_app_log, read_error_log, read_gateway_error_log, - read_gateway_log, read_helper_log, read_raw_config, record_install_experience, - refresh_discord_guild_channels, refresh_model_catalog, remote_analyze_sessions, - remote_apply_config_patch, remote_backup_before_upgrade, remote_chat_via_openclaw, - remote_check_openclaw_update, remote_clear_all_sessions, remote_delete_backup, - remote_delete_cron_job, remote_delete_model_profile, remote_delete_sessions_by_ids, - remote_deploy_watchdog, remote_diagnose_doctor_assistant, remote_diagnose_primary_via_rescue, + list_history, list_model_profiles, list_recipe_instances, list_recipe_runs, + list_recipe_workspace_entries, list_recipes, list_recipes_from_source_text, + list_registered_instances, list_session_files, list_ssh_config_hosts, list_ssh_hosts, + local_openclaw_cli_available, local_openclaw_config_exists, log_app_event, manage_rescue_bot, + migrate_legacy_instances, open_url, plan_recipe, plan_recipe_source, precheck_auth, + precheck_instance, precheck_registry, precheck_transport, preview_rollback, preview_session, + probe_ssh_connection_profile, push_model_profiles_to_local_openclaw, + push_model_profiles_to_remote_openclaw, push_related_secrets_to_remote, read_app_log, + read_error_log, read_gateway_error_log, read_gateway_log, read_helper_log, read_raw_config, + read_recipe_workspace_source, record_install_experience, refresh_discord_guild_channels, + refresh_model_catalog, remote_analyze_sessions, remote_apply_config_patch, + remote_backup_before_upgrade, remote_chat_via_openclaw, remote_check_openclaw_update, + remote_clear_all_sessions, remote_delete_backup, remote_delete_cron_job, + remote_delete_model_profile, remote_delete_sessions_by_ids, remote_deploy_watchdog, + remote_diagnose_doctor_assistant, remote_diagnose_primary_via_rescue, remote_extract_model_profiles_from_config, remote_fix_issues, remote_get_channels_config_snapshot, remote_get_channels_runtime_snapshot, remote_get_cron_config_snapshot, remote_get_cron_runs, remote_get_cron_runtime_snapshot, @@ -53,12 +56,12 @@ use crate::commands::{ remote_uninstall_watchdog, remote_upsert_model_profile, remote_write_raw_config, repair_doctor_assistant, repair_primary_via_rescue, resolve_api_keys, resolve_provider_auth, restart_gateway, restore_from_backup, rollback, run_doctor_command, run_openclaw_upgrade, - set_active_clawpal_data_dir, set_active_openclaw_home, set_agent_model, - set_bug_report_settings, set_global_model, set_session_model_override, + save_recipe_workspace_source, set_active_clawpal_data_dir, set_active_openclaw_home, + set_agent_model, set_bug_report_settings, set_global_model, set_session_model_override, set_ssh_transfer_speed_ui_preference, setup_agent_identity, sftp_list_dir, sftp_read_file, sftp_remove_file, sftp_write_file, ssh_connect, ssh_connect_with_passphrase, ssh_disconnect, ssh_exec, ssh_status, start_watchdog, stop_watchdog, test_model_profile, trigger_cron_job, - uninstall_watchdog, upsert_model_profile, upsert_ssh_host, + uninstall_watchdog, upsert_model_profile, upsert_ssh_host, validate_recipe_source_text, }; use crate::install::commands::{ install_create_session, install_decide_target, install_get_session, install_list_methods, @@ -70,12 +73,14 @@ use crate::ssh::SshConnectionPool; pub mod access_discovery; pub mod agent_fallback; +pub mod agent_identity; pub mod bridge_client; pub mod bug_report; pub mod cli_runner; pub mod commands; pub mod config_io; pub mod doctor; +pub mod execution_spec; pub mod history; pub mod install; pub mod json_util; @@ -86,8 +91,30 @@ pub mod openclaw_doc_resolver; pub mod path_fix; pub mod prompt_templates; pub mod recipe; +pub mod recipe_adapter; +pub mod recipe_bundle; +pub mod recipe_executor; +pub mod recipe_planner; +pub mod recipe_runtime; +pub mod recipe_store; +pub mod recipe_workspace; pub mod ssh; +#[cfg(test)] +mod execution_spec_tests; +#[cfg(test)] +mod recipe_adapter_tests; +#[cfg(test)] +mod recipe_bundle_tests; +#[cfg(test)] +mod recipe_executor_tests; +#[cfg(test)] +mod recipe_planner_tests; +#[cfg(test)] +mod recipe_store_tests; +#[cfg(test)] +mod recipe_workspace_tests; + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) @@ -133,6 +160,18 @@ pub fn run() { get_session_model_override, clear_session_model_override, list_recipes, + list_recipes_from_source_text, + validate_recipe_source_text, + list_recipe_workspace_entries, + read_recipe_workspace_source, + save_recipe_workspace_source, + delete_recipe_workspace_source, + export_recipe_source, + execute_recipe, + plan_recipe, + plan_recipe_source, + list_recipe_instances, + list_recipe_runs, list_model_profiles, get_cached_model_catalog, refresh_model_catalog, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0740c726..de294dfc 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -13,6 +13,7 @@ pub struct OpenClawPaths { pub clawpal_dir: PathBuf, pub history_dir: PathBuf, pub metadata_path: PathBuf, + pub recipe_runtime_dir: PathBuf, } fn expand_user_path(raw: &str) -> PathBuf { @@ -72,6 +73,7 @@ pub fn resolve_paths() -> OpenClawPaths { let config_path = openclaw_dir.join("openclaw.json"); let history_dir = clawpal_dir.join("history"); let metadata_path = clawpal_dir.join("metadata.json"); + let recipe_runtime_dir = clawpal_dir.join("recipe-runtime"); OpenClawPaths { openclaw_dir: openclaw_dir.clone(), @@ -80,5 +82,6 @@ pub fn resolve_paths() -> OpenClawPaths { clawpal_dir, history_dir, metadata_path, + recipe_runtime_dir, } } diff --git a/src-tauri/src/recipe.rs b/src-tauri/src/recipe.rs index 72a9d846..50984292 100644 --- a/src-tauri/src/recipe.rs +++ b/src-tauri/src/recipe.rs @@ -6,11 +6,20 @@ use std::{ path::{Path, PathBuf}, }; +use crate::execution_spec::ExecutionSpec; +use crate::recipe_bundle::RecipeBundle; +use crate::{ + execution_spec::validate_execution_spec, + recipe_adapter::{build_recipe_spec_template, canonical_recipe_bundle}, + recipe_bundle::validate_execution_spec_against_bundle, +}; + const BUILTIN_RECIPES_JSON: &str = include_str!("../recipes.json"); #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] enum RecipeDocument { + Single(Recipe), List(Vec), Wrapped { recipes: Vec }, } @@ -56,6 +65,10 @@ pub struct Recipe { pub difficulty: String, pub params: Vec, pub steps: Vec, + #[serde(skip_serializing, default)] + pub bundle: Option, + #[serde(skip_serializing, default)] + pub execution_spec_template: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -91,6 +104,27 @@ pub struct ApplyResult { pub errors: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceDiagnostic { + pub category: String, + pub severity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recipe_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceDiagnostics { + #[serde(default)] + pub errors: Vec, + #[serde(default)] + pub warnings: Vec, +} + pub fn builtin_recipes() -> Vec { parse_recipes_document(BUILTIN_RECIPES_JSON).unwrap_or_else(|_| Vec::new()) } @@ -111,11 +145,19 @@ fn expand_user_path(candidate: &str) -> PathBuf { fn parse_recipes_document(text: &str) -> Result, String> { let document: RecipeDocument = json5::from_str(text).map_err(|e| e.to_string())?; match document { + RecipeDocument::Single(recipe) => Ok(vec![recipe]), RecipeDocument::List(recipes) => Ok(recipes), RecipeDocument::Wrapped { recipes } => Ok(recipes), } } +pub fn load_recipes_from_source_text(text: &str) -> Result, String> { + if text.trim().is_empty() { + return Err("empty recipe source".into()); + } + parse_recipes_document(text) +} + pub fn load_recipes_from_source(source: &str) -> Result, String> { if source.trim().is_empty() { return Err("empty recipe source".into()); @@ -127,7 +169,7 @@ pub fn load_recipes_from_source(source: &str) -> Result, String> { return Err(format!("request failed: {}", response.status())); } let text = response.text().map_err(|e| e.to_string())?; - parse_recipes_document(&text) + load_recipes_from_source_text(&text) } else { let path = expand_user_path(source); let path = Path::new(&path); @@ -135,7 +177,7 @@ pub fn load_recipes_from_source(source: &str) -> Result, String> { return Err(format!("recipe file not found: {}", path.to_string_lossy())); } let text = fs::read_to_string(path).map_err(|e| e.to_string())?; - parse_recipes_document(&text) + load_recipes_from_source_text(&text) } } @@ -177,6 +219,84 @@ pub fn find_recipe_with_source(id: &str, source: Option) -> Option Result { + let mut diagnostics = RecipeSourceDiagnostics::default(); + let recipes = match load_recipes_from_source_text(text) { + Ok(recipes) => recipes, + Err(error) => { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "parse".into(), + severity: "error".into(), + recipe_id: None, + path: None, + message: error, + }); + return Ok(diagnostics); + } + }; + + for recipe in &recipes { + validate_recipe_definition(recipe, &mut diagnostics); + } + + Ok(diagnostics) +} + +fn validate_recipe_definition(recipe: &Recipe, diagnostics: &mut RecipeSourceDiagnostics) { + if let Some(template) = &recipe.execution_spec_template { + if template.actions.len() != recipe.steps.len() { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "alignment".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("steps".into()), + message: format!( + "recipe '{}' declares {} UI step(s) but {} execution action(s)", + recipe.id, + recipe.steps.len(), + template.actions.len() + ), + }); + } + } + + let spec = match build_recipe_spec_template(recipe) { + Ok(spec) => spec, + Err(error) => { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "schema".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("executionSpecTemplate".into()), + message: error, + }); + return; + } + }; + + if let Err(error) = validate_execution_spec(&spec) { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "schema".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("executionSpecTemplate".into()), + message: error, + }); + return; + } + + let bundle = canonical_recipe_bundle(recipe, &spec); + if let Err(error) = validate_execution_spec_against_bundle(&bundle, &spec) { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "bundle".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("bundle".into()), + message: error, + }); + } +} + pub fn validate(recipe: &Recipe, params: &Map) -> Vec { let mut errors = Vec::new(); for p in &recipe.params { @@ -218,25 +338,114 @@ pub fn validate(recipe: &Recipe, params: &Map) -> Vec { errors } -fn render_patch_template(template: &str, params: &Map) -> String { +fn param_value_to_string(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + _ => value.to_string(), + } +} + +fn extract_placeholders(text: &str) -> Vec { + Regex::new(r"\{\{(\w+)\}\}") + .ok() + .map(|regex| { + regex + .captures_iter(text) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() + }) + .unwrap_or_default() +} + +pub fn render_template_string(template: &str, params: &Map) -> String { let mut text = template.to_string(); for (k, v) in params { let placeholder = format!("{{{{{}}}}}", k); - let replacement = match v { - Value::String(s) => s.clone(), - _ => v.to_string(), - }; + let replacement = param_value_to_string(v); text = text.replace(&placeholder, &replacement); } text } +pub fn render_template_value(value: &Value, params: &Map) -> Value { + match value { + Value::String(text) => { + if let Some(param_id) = text + .strip_prefix("{{") + .and_then(|rest| rest.strip_suffix("}}")) + { + if param_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + return params + .get(param_id) + .cloned() + .unwrap_or_else(|| Value::String(String::new())); + } + } + Value::String(render_template_string(text, params)) + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| render_template_value(item, params)) + .collect(), + ), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| { + ( + render_template_string(key, params), + render_template_value(value, params), + ) + }) + .collect(), + ), + _ => value.clone(), + } +} + +pub fn render_step_args( + args: &Map, + params: &Map, +) -> Map { + args.iter() + .map(|(key, value)| (key.clone(), render_template_value(value, params))) + .collect() +} + +pub fn step_references_empty_param(step: &RecipeStep, params: &Map) -> bool { + fn value_references_empty_param(value: &Value, params: &Map) -> bool { + match value { + Value::String(text) => extract_placeholders(text).into_iter().any(|param_id| { + params + .get(¶m_id) + .and_then(Value::as_str) + .map(|value| value.trim().is_empty()) + .unwrap_or(false) + }), + Value::Array(items) => items + .iter() + .any(|item| value_references_empty_param(item, params)), + Value::Object(map) => map + .values() + .any(|item| value_references_empty_param(item, params)), + _ => false, + } + } + + step.args + .values() + .any(|value| value_references_empty_param(value, params)) +} + pub fn build_candidate_config_from_template( current: &Value, template: &str, params: &Map, ) -> Result<(Value, Vec), String> { - let rendered = render_patch_template(template, params); + let rendered = render_template_string(template, params); let patch: Value = json5::from_str(&rendered).map_err(|e| e.to_string())?; let mut merged = current.clone(); let mut changes = Vec::new(); diff --git a/src-tauri/src/recipe_adapter.rs b/src-tauri/src/recipe_adapter.rs new file mode 100644 index 00000000..c7124435 --- /dev/null +++ b/src-tauri/src/recipe_adapter.rs @@ -0,0 +1,464 @@ +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; + +use crate::execution_spec::{ + validate_execution_spec, ExecutionAction, ExecutionCapabilities, ExecutionMetadata, + ExecutionResourceClaim, ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, +}; +use crate::recipe::{ + render_step_args, render_template_value, step_references_empty_param, validate, Recipe, + RecipeParam, RecipeStep, +}; +use crate::recipe_bundle::{ + validate_execution_spec_against_bundle, BundleCapabilities, BundleCompatibility, + BundleExecution, BundleMetadata, BundleResources, BundleRunner, RecipeBundle, +}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RecipeSourceDocument { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub tags: Vec, + pub difficulty: String, + pub params: Vec, + pub steps: Vec, + pub bundle: RecipeBundle, + pub execution_spec_template: ExecutionSpec, +} + +pub fn compile_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let errors = validate(recipe, params); + if !errors.is_empty() { + return Err(errors.join(", ")); + } + + if recipe.execution_spec_template.is_some() { + return compile_structured_recipe_to_spec(recipe, params); + } + + compile_step_recipe_to_spec(recipe, params) +} + +pub fn export_recipe_source(recipe: &Recipe) -> Result { + let execution_spec_template = build_recipe_spec_template(recipe)?; + let bundle = canonical_recipe_bundle(recipe, &execution_spec_template); + let document = RecipeSourceDocument { + id: recipe.id.clone(), + name: recipe.name.clone(), + description: recipe.description.clone(), + version: recipe.version.clone(), + tags: recipe.tags.clone(), + difficulty: recipe.difficulty.clone(), + params: recipe.params.clone(), + steps: recipe.steps.clone(), + bundle, + execution_spec_template, + }; + serde_json::to_string_pretty(&document).map_err(|error| error.to_string()) +} + +pub(crate) fn build_recipe_spec_template(recipe: &Recipe) -> Result { + if let Some(template) = &recipe.execution_spec_template { + return Ok(template.clone()); + } + build_step_recipe_template(recipe) +} + +fn compile_structured_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let template = recipe + .execution_spec_template + .as_ref() + .ok_or_else(|| format!("recipe '{}' is missing executionSpecTemplate", recipe.id))?; + let template_value = serde_json::to_value(template).map_err(|error| error.to_string())?; + let rendered_template = render_template_value(&template_value, params); + let mut spec: ExecutionSpec = + serde_json::from_value(rendered_template).map_err(|error| error.to_string())?; + + filter_optional_structured_actions(recipe, params, &mut spec)?; + normalize_recipe_spec(recipe, &mut spec, "structuredTemplate"); + + if let Some((used_capabilities, claims)) = infer_recipe_action_requirements(&spec.actions) { + spec.capabilities.used_capabilities = used_capabilities; + spec.resources.claims = claims; + } + + validate_recipe_spec(recipe, &spec)?; + Ok(spec) +} + +fn compile_step_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + let mut actions = Vec::new(); + + for step in &recipe.steps { + if step_references_empty_param(step, params) { + continue; + } + + let rendered_args = render_step_args(&step.args, params); + collect_action_requirements( + step.action.as_str(), + &rendered_args, + &mut used_capabilities, + &mut claims, + ); + actions.push(build_recipe_action(step, rendered_args)?); + } + + let execution_kind = if actions + .iter() + .all(|action| action.kind.as_deref() == Some("config_patch")) + { + "attachment" + } else { + "job" + }; + + let mut spec = ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some(recipe.id.clone()), + digest: None, + }, + source: Value::Object(Map::new()), + target: Value::Object(Map::new()), + execution: ExecutionTarget { + kind: execution_kind.into(), + }, + capabilities: ExecutionCapabilities { used_capabilities }, + resources: ExecutionResources { claims }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": actions.len(), + }), + actions, + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })], + }; + + normalize_recipe_spec(recipe, &mut spec, "stepAdapter"); + validate_recipe_spec(recipe, &spec)?; + Ok(spec) +} + +fn build_step_recipe_template(recipe: &Recipe) -> Result { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + let mut actions = Vec::new(); + + for step in &recipe.steps { + collect_action_requirements( + step.action.as_str(), + &step.args, + &mut used_capabilities, + &mut claims, + ); + actions.push(build_recipe_action(step, step.args.clone())?); + } + + let execution_kind = if actions + .iter() + .all(|action| action.kind.as_deref() == Some("config_patch")) + { + "attachment" + } else { + "job" + }; + + Ok(ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some(recipe.id.clone()), + digest: None, + }, + source: Value::Object(Map::new()), + target: Value::Object(Map::new()), + execution: ExecutionTarget { + kind: execution_kind.into(), + }, + capabilities: ExecutionCapabilities { used_capabilities }, + resources: ExecutionResources { claims }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": actions.len(), + }), + actions, + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })], + }) +} + +fn normalize_recipe_spec(recipe: &Recipe, spec: &mut ExecutionSpec, compiler: &str) { + if spec.metadata.name.is_none() { + spec.metadata.name = Some(recipe.id.clone()); + } + + let mut source = spec.source.as_object().cloned().unwrap_or_default(); + source.insert("recipeId".into(), Value::String(recipe.id.clone())); + source.insert( + "recipeVersion".into(), + Value::String(recipe.version.clone()), + ); + source.insert("recipeCompiler".into(), Value::String(compiler.into())); + spec.source = Value::Object(source); + + if let Some(desired_state) = spec.desired_state.as_object_mut() { + desired_state.insert("actionCount".into(), json!(spec.actions.len())); + } else { + spec.desired_state = json!({ + "actionCount": spec.actions.len(), + }); + } + + if spec.outputs.is_empty() { + spec.outputs.push(json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })); + } +} + +fn validate_recipe_spec(recipe: &Recipe, spec: &ExecutionSpec) -> Result<(), String> { + if let Some(bundle) = &recipe.bundle { + validate_execution_spec_against_bundle(bundle, spec) + } else { + validate_execution_spec(spec) + } +} + +pub(crate) fn canonical_recipe_bundle(recipe: &Recipe, spec: &ExecutionSpec) -> RecipeBundle { + if let Some(bundle) = &recipe.bundle { + return bundle.clone(); + } + + let allowed_capabilities = spec + .capabilities + .used_capabilities + .iter() + .cloned() + .collect::>() + .into_iter() + .collect(); + let supported_resource_kinds = spec + .resources + .claims + .iter() + .map(|claim| claim.kind.clone()) + .collect::>() + .into_iter() + .collect(); + + RecipeBundle { + api_version: "strategy.platform/v1".into(), + kind: "StrategyBundle".into(), + metadata: BundleMetadata { + name: Some(recipe.id.clone()), + version: Some(recipe.version.clone()), + description: Some(recipe.description.clone()), + }, + compatibility: BundleCompatibility::default(), + inputs: Vec::new(), + capabilities: BundleCapabilities { + allowed: allowed_capabilities, + }, + resources: BundleResources { + supported_kinds: supported_resource_kinds, + }, + execution: BundleExecution { + supported_kinds: vec![spec.execution.kind.clone()], + }, + runner: BundleRunner::default(), + outputs: spec.outputs.clone(), + } +} + +fn filter_optional_structured_actions( + recipe: &Recipe, + params: &Map, + spec: &mut ExecutionSpec, +) -> Result<(), String> { + let skipped_step_indices: BTreeSet = recipe + .steps + .iter() + .enumerate() + .filter(|(_, step)| step_references_empty_param(step, params)) + .map(|(index, _)| index) + .collect(); + if skipped_step_indices.is_empty() { + return Ok(()); + } + + if spec.actions.len() != recipe.steps.len() { + return Err(format!( + "recipe '{}' executionSpecTemplate must align actions with UI steps for optional step elision", + recipe.id + )); + } + + spec.actions = spec + .actions + .iter() + .enumerate() + .filter_map(|(index, action)| { + if skipped_step_indices.contains(&index) { + None + } else { + Some(action.clone()) + } + }) + .collect(); + Ok(()) +} + +fn infer_recipe_action_requirements( + actions: &[ExecutionAction], +) -> Option<(Vec, Vec)> { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + + for action in actions { + let kind = action.kind.as_deref()?; + let args = action.args.as_object()?; + if !matches!( + kind, + "create_agent" | "setup_identity" | "bind_channel" | "config_patch" + ) { + return None; + } + + collect_action_requirements(kind, args, &mut used_capabilities, &mut claims); + } + + Some((used_capabilities, claims)) +} + +fn build_recipe_action( + step: &RecipeStep, + mut rendered_args: Map, +) -> Result { + let args = if step.action == "config_patch" { + let mut action_args = Map::new(); + if let Some(Value::String(patch_template)) = rendered_args.remove("patchTemplate") { + let patch: Value = + json5::from_str(&patch_template).map_err(|error| error.to_string())?; + action_args.insert("patchTemplate".into(), Value::String(patch_template)); + action_args.insert("patch".into(), patch); + } + action_args.extend(rendered_args); + Value::Object(action_args) + } else { + Value::Object(rendered_args) + }; + + Ok(ExecutionAction { + kind: Some(step.action.clone()), + name: Some(step.label.clone()), + args, + }) +} + +fn collect_action_requirements( + action_kind: &str, + rendered_args: &Map, + used_capabilities: &mut Vec, + claims: &mut Vec, +) { + match action_kind { + "create_agent" => { + push_capability(used_capabilities, "agent.manage"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "setup_identity" => { + push_capability(used_capabilities, "agent.identity.write"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "bind_channel" => { + push_capability(used_capabilities, "binding.manage"); + let channel_id = rendered_args + .get("peerId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let agent_id = rendered_args + .get("agentId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: agent_id, + path: None, + }, + ); + } + "config_patch" => { + push_capability(used_capabilities, "config.write"); + push_claim( + claims, + ExecutionResourceClaim { + kind: "file".into(), + id: Some("openclaw.config".into()), + target: None, + path: Some("openclaw.config".into()), + }, + ); + } + _ => {} + } +} + +fn push_capability(target: &mut Vec, capability: &str) { + if !target.iter().any(|item| item == capability) { + target.push(capability.into()); + } +} + +fn push_optional_id_claim( + claims: &mut Vec, + kind: &str, + id: Option<&Value>, +) { + let id = id.and_then(Value::as_str).map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: kind.into(), + id, + target: None, + path: None, + }, + ); +} + +fn push_claim(claims: &mut Vec, next: ExecutionResourceClaim) { + let exists = claims.iter().any(|claim| { + claim.kind == next.kind + && claim.id == next.id + && claim.target == next.target + && claim.path == next.path + }); + if !exists { + claims.push(next); + } +} diff --git a/src-tauri/src/recipe_adapter_tests.rs b/src-tauri/src/recipe_adapter_tests.rs new file mode 100644 index 00000000..f07b1013 --- /dev/null +++ b/src-tauri/src/recipe_adapter_tests.rs @@ -0,0 +1,314 @@ +use serde_json::{Map, Value}; + +use crate::recipe::{builtin_recipes, validate_recipe_source, Recipe, RecipeParam, RecipeStep}; +use crate::recipe_adapter::{compile_recipe_to_spec, export_recipe_source}; + +fn sample_params() -> Map { + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("bot-alpha".into())); + params.insert("model".into(), Value::String("__default__".into())); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert("independent".into(), Value::String("true".into())); + params.insert("name".into(), Value::String("Bot Alpha".into())); + params.insert("emoji".into(), Value::String(":claw:".into())); + params.insert( + "persona".into(), + Value::String("You are a focused channel assistant.".into()), + ); + params +} + +#[test] +fn recipe_compiles_to_attachment_or_job_spec() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "dedicated-channel-agent") + .expect("builtin recipe"); + + let spec = compile_recipe_to_spec(&recipe, &sample_params()).expect("compile spec"); + + assert!(matches!(spec.execution.kind.as_str(), "attachment" | "job")); + assert!(!spec.actions.is_empty()); + assert_eq!( + spec.source.get("recipeId").and_then(Value::as_str), + Some(recipe.id.as_str()) + ); + assert_eq!( + spec.source.get("recipeCompiler").and_then(Value::as_str), + Some("structuredTemplate") + ); + assert!(spec.source.get("legacyRecipeId").is_none()); +} + +#[test] +fn config_patch_only_recipe_compiles_to_attachment_spec() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + + let spec = compile_recipe_to_spec(&recipe, &sample_params()).expect("compile spec"); + + assert_eq!(spec.execution.kind, "attachment"); + assert_eq!(spec.actions.len(), 1); + assert_eq!( + spec.outputs[0].get("kind").and_then(Value::as_str), + Some("recipe-summary") + ); + let patch = spec.actions[0] + .args + .get("patch") + .and_then(Value::as_object) + .expect("rendered patch"); + assert!(patch.get("channels").is_some()); + let rendered_patch = serde_json::to_string(&spec.actions[0].args).expect("patch json"); + assert!(rendered_patch.contains("\"guild-1\"")); + assert!(rendered_patch.contains("\"channel-1\"")); + assert!(!rendered_patch.contains("{{guild_id}}")); +} + +#[test] +fn structured_recipe_template_skips_optional_actions_with_empty_params() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "dedicated-channel-agent") + .expect("builtin recipe"); + let mut params = sample_params(); + params.insert("name".into(), Value::String(String::new())); + params.insert("emoji".into(), Value::String(String::new())); + params.insert("persona".into(), Value::String(String::new())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!(spec.actions.len(), 2); + assert_eq!(spec.actions[0].kind.as_deref(), Some("create_agent")); + assert_eq!(spec.actions[1].kind.as_deref(), Some("bind_channel")); +} + +#[test] +fn export_recipe_source_normalizes_step_only_recipe_to_structured_document() { + let recipe = Recipe { + id: "legacy-channel-persona".into(), + name: "Legacy Channel Persona".into(), + description: "Set channel persona with steps only".into(), + version: "1.0.0".into(), + tags: vec!["discord".into(), "persona".into()], + difficulty: "easy".into(), + params: vec![ + RecipeParam { + id: "guild_id".into(), + label: "Guild".into(), + kind: "discord_guild".into(), + required: true, + pattern: None, + min_length: None, + max_length: None, + placeholder: None, + depends_on: None, + default_value: None, + }, + RecipeParam { + id: "channel_id".into(), + label: "Channel".into(), + kind: "discord_channel".into(), + required: true, + pattern: None, + min_length: None, + max_length: None, + placeholder: None, + depends_on: None, + default_value: None, + }, + ], + steps: vec![RecipeStep { + action: "config_patch".into(), + label: "Set channel persona".into(), + args: serde_json::from_value(serde_json::json!({ + "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"hello\"}}}}}}}" + })) + .expect("step args"), + }], + bundle: None, + execution_spec_template: None, + }; + + let exported = export_recipe_source(&recipe).expect("export source"); + + assert!(exported.contains("\"bundle\"")); + assert!(exported.contains("\"executionSpecTemplate\"")); + assert!(exported.contains("\"supportedKinds\": [\n \"attachment\"")); + assert!(exported.contains("\"{{guild_id}}\"")); +} + +#[test] +fn exported_recipe_source_validates_as_structured_document() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + let source = export_recipe_source(&recipe).expect("export source"); + + let diagnostics = validate_recipe_source(&source).expect("validate source"); + + assert!(diagnostics.errors.is_empty()); +} + +#[test] +fn validate_recipe_source_flags_parse_errors() { + let diagnostics = validate_recipe_source("{ broken").expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "parse"); +} + +#[test] +fn validate_recipe_source_flags_bundle_consistency_errors() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "bundle-mismatch", + "name": "Bundle Mismatch", + "description": "Invalid bundle/spec pairing", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "bundle"); +} + +#[test] +fn validate_recipe_source_flags_step_alignment_errors() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "step-mismatch", + "name": "Step Mismatch", + "description": "Invalid step/action alignment", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [ + { "action": "config_patch", "label": "First", "args": {} }, + { "action": "config_patch", "label": "Second", "args": {} } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "config_patch", "name": "Only action", "args": {} } + ], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "alignment"); +} + +#[test] +fn validate_recipe_source_flags_hidden_actions_without_ui_steps() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "hidden-actions", + "name": "Hidden Actions", + "description": "Execution actions without UI steps", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "config_patch", "name": "Only action", "args": {} } + ], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "alignment"); +} diff --git a/src-tauri/src/recipe_bundle.rs b/src-tauri/src/recipe_bundle.rs new file mode 100644 index 00000000..6dbfeb42 --- /dev/null +++ b/src-tauri/src/recipe_bundle.rs @@ -0,0 +1,103 @@ +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const SUPPORTED_EXECUTION_KINDS: &[&str] = &["job", "service", "schedule", "attachment"]; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleMetadata { + pub name: Option, + pub version: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleCompatibility { + pub min_runner_version: Option, + pub target_platforms: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleCapabilities { + pub allowed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleResources { + pub supported_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleExecution { + pub supported_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleRunner { + pub name: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct RecipeBundle { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + pub metadata: BundleMetadata, + pub compatibility: BundleCompatibility, + pub inputs: Vec, + pub capabilities: BundleCapabilities, + pub resources: BundleResources, + pub execution: BundleExecution, + pub runner: BundleRunner, + pub outputs: Vec, +} + +pub fn parse_recipe_bundle(raw: &str) -> Result { + let bundle: RecipeBundle = parse_structured_document(raw)?; + validate_recipe_bundle(&bundle)?; + Ok(bundle) +} + +pub fn validate_recipe_bundle(bundle: &RecipeBundle) -> Result<(), String> { + if bundle.kind != "StrategyBundle" { + return Err(format!("unsupported document kind: {}", bundle.kind)); + } + + for kind in &bundle.execution.supported_kinds { + validate_execution_kind(kind)?; + } + Ok(()) +} + +pub fn validate_execution_spec_against_bundle( + bundle: &RecipeBundle, + spec: &crate::execution_spec::ExecutionSpec, +) -> Result<(), String> { + crate::execution_spec::validate_execution_spec_against_bundle(spec, bundle) +} + +pub(crate) fn parse_structured_document(raw: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(raw) + .or_else(|_| json5::from_str(raw)) + .or_else(|_| serde_yaml::from_str(raw)) + .map_err(|error| format!("failed to parse structured document: {error}")) +} + +pub(crate) fn validate_execution_kind(kind: &str) -> Result<(), String> { + if SUPPORTED_EXECUTION_KINDS.contains(&kind) { + Ok(()) + } else { + Err(format!("unsupported execution kind: {kind}")) + } +} diff --git a/src-tauri/src/recipe_bundle_tests.rs b/src-tauri/src/recipe_bundle_tests.rs new file mode 100644 index 00000000..bd472fa9 --- /dev/null +++ b/src-tauri/src/recipe_bundle_tests.rs @@ -0,0 +1,10 @@ +use crate::recipe_bundle::parse_recipe_bundle; + +#[test] +fn recipe_bundle_rejects_unknown_execution_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +execution: { supportedKinds: [workflow] }"#; + + assert!(parse_recipe_bundle(raw).is_err()); +} diff --git a/src-tauri/src/recipe_executor.rs b/src-tauri/src/recipe_executor.rs new file mode 100644 index 00000000..fb10583f --- /dev/null +++ b/src-tauri/src/recipe_executor.rs @@ -0,0 +1,421 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::execution_spec::ExecutionSpec; +use crate::recipe_runtime::systemd; +use crate::recipe_store::Artifact as RecipeRuntimeArtifact; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct MaterializedExecutionPlan { + pub execution_kind: String, + pub unit_name: String, + pub commands: Vec>, + pub resources: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionRoute { + pub runner: String, + pub target_kind: String, + pub host_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipeRequest { + pub spec: ExecutionSpec, + #[serde(default)] + pub source_origin: Option, + #[serde(default)] + pub source_text: Option, + #[serde(default)] + pub workspace_slug: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipePrepared { + pub run_id: String, + pub route: ExecutionRoute, + pub plan: MaterializedExecutionPlan, + pub summary: String, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipeResult { + pub run_id: String, + pub instance_id: String, + pub summary: String, + pub warnings: Vec, +} + +fn has_command_value(value: Option<&Value>) -> bool { + value + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) +} + +fn has_structured_job_command(spec: &ExecutionSpec) -> bool { + has_command_value(spec.desired_state.get("command")) + || spec + .desired_state + .get("job") + .and_then(|value| value.get("command")) + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) + || spec.actions.iter().any(|action| { + action + .args + .get("command") + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) + }) +} + +fn has_structured_schedule(spec: &ExecutionSpec) -> bool { + spec.desired_state + .get("schedule") + .and_then(|value| value.get("onCalendar")) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || spec.actions.iter().any(|action| { + action + .args + .get("onCalendar") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) +} + +fn has_structured_attachment_state(spec: &ExecutionSpec) -> bool { + spec.desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + .is_some() + || spec + .desired_state + .get("envPatch") + .and_then(Value::as_object) + .is_some() +} + +fn collect_claim_resource_refs(spec: &ExecutionSpec) -> Vec { + let mut refs = Vec::new(); + for claim in &spec.resources.claims { + for value in [&claim.id, &claim.target, &claim.path] { + if let Some(value) = value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !refs.iter().any(|existing| existing == value) { + refs.push(value.to_string()); + } + } + } + } + refs +} + +fn action_only_materialized_plan(spec: &ExecutionSpec) -> MaterializedExecutionPlan { + MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: String::new(), + commands: Vec::new(), + resources: collect_claim_resource_refs(spec), + warnings: Vec::new(), + } +} + +fn summary_subject(spec: &ExecutionSpec, plan: &MaterializedExecutionPlan) -> String { + if !plan.unit_name.trim().is_empty() { + return plan.unit_name.clone(); + } + + spec.metadata + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "recipe".into()) +} + +pub fn materialize_execution_plan( + spec: &ExecutionSpec, +) -> Result { + match spec.execution.kind.as_str() { + "job" if has_structured_job_command(spec) => { + let runtime_plan = systemd::materialize_job(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "service" if has_structured_job_command(spec) => { + let runtime_plan = systemd::materialize_service(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "schedule" if has_structured_job_command(spec) && has_structured_schedule(spec) => { + let runtime_plan = systemd::materialize_schedule(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "attachment" if has_structured_attachment_state(spec) => { + let runtime_plan = systemd::materialize_attachment(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "job" | "attachment" if !spec.actions.is_empty() => Ok(action_only_materialized_plan(spec)), + other => Err(format!("unsupported execution kind: {}", other)), + } +} + +pub fn route_execution(target: &Value) -> Result { + let target_kind = target + .get("kind") + .and_then(Value::as_str) + .unwrap_or("local") + .to_string(); + + match target_kind.as_str() { + "local" | "docker_local" => Ok(ExecutionRoute { + runner: "local".into(), + target_kind, + host_id: None, + }), + "remote" | "remote_ssh" => Ok(ExecutionRoute { + runner: "remote_ssh".into(), + target_kind, + host_id: target + .get("hostId") + .and_then(Value::as_str) + .map(|value| value.to_string()), + }), + other => Err(format!("unsupported execution target kind: {}", other)), + } +} + +fn push_unique_artifact( + artifacts: &mut Vec, + artifact: RecipeRuntimeArtifact, +) { + if !artifacts.iter().any(|existing| { + existing.kind == artifact.kind + && existing.label == artifact.label + && existing.path == artifact.path + }) { + artifacts.push(artifact); + } +} + +fn push_unique_command(commands: &mut Vec>, command: Vec) { + if !commands.iter().any(|existing| existing == &command) { + commands.push(command); + } +} + +pub fn build_runtime_artifacts( + spec: &ExecutionSpec, + prepared: &ExecuteRecipePrepared, +) -> Vec { + let mut artifacts = Vec::new(); + let unit_name = prepared.plan.unit_name.trim(); + + match spec.execution.kind.as_str() { + "job" | "service" if !unit_name.is_empty() => { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:unit", prepared.run_id), + kind: "systemdUnit".into(), + label: prepared.plan.unit_name.clone(), + path: Some(prepared.plan.unit_name.clone()), + }, + ); + } + "schedule" if !unit_name.is_empty() => { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:unit", prepared.run_id), + kind: "systemdUnit".into(), + label: prepared.plan.unit_name.clone(), + path: Some(prepared.plan.unit_name.clone()), + }, + ); + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:timer", prepared.run_id), + kind: "systemdTimer".into(), + label: format!("{}.timer", prepared.plan.unit_name), + path: Some(format!("{}.timer", prepared.plan.unit_name)), + }, + ); + } + "attachment" => { + if systemd::render_env_patch_dropin_content(spec).is_some() { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:daemon-reload", prepared.run_id), + kind: "systemdDaemonReload".into(), + label: "systemctl --user daemon-reload".into(), + path: None, + }, + ); + } + + if let Some(path) = systemd::env_patch_dropin_path(spec) { + if let Some(target) = systemd::attachment_target_unit(spec) { + let name = systemd::env_patch_dropin_name(spec); + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:env-dropin", prepared.run_id), + kind: "systemdDropIn".into(), + label: format!("{}:{}", target, name), + path: Some(path), + }, + ); + } + } + + if let Some(drop_in) = spec + .desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + { + let target = drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let name = drop_in + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let (Some(target), Some(name)) = (target, name) { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:dropin", prepared.run_id), + kind: "systemdDropIn".into(), + label: format!("{}:{}", target, name), + path: Some(format!("~/.config/systemd/user/{}.d/{}", target, name)), + }, + ); + } + } + } + _ => {} + } + + artifacts +} + +pub fn build_cleanup_commands(artifacts: &[RecipeRuntimeArtifact]) -> Vec> { + let mut commands = Vec::new(); + + for artifact in artifacts { + match artifact.kind.as_str() { + "systemdUnit" | "systemdTimer" => { + let target = artifact + .path + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&artifact.label); + push_unique_command( + &mut commands, + vec![ + "systemctl".into(), + "--user".into(), + "stop".into(), + target.to_string(), + ], + ); + push_unique_command( + &mut commands, + vec![ + "systemctl".into(), + "--user".into(), + "reset-failed".into(), + target.to_string(), + ], + ); + } + "systemdDaemonReload" => { + push_unique_command( + &mut commands, + vec!["systemctl".into(), "--user".into(), "daemon-reload".into()], + ); + } + _ => {} + } + } + + commands +} + +pub fn execute_recipe(request: ExecuteRecipeRequest) -> Result { + let plan = materialize_execution_plan(&request.spec)?; + let route = route_execution(&request.spec.target)?; + let operation_count = if !plan.commands.is_empty() { + plan.commands.len() + } else { + request.spec.actions.len() + }; + let operation_label = if !plan.commands.is_empty() { + "command" + } else { + "action" + }; + let summary = format!( + "{} via {} ({} {}{})", + summary_subject(&request.spec, &plan), + route.runner, + operation_count, + operation_label, + if operation_count == 1 { "" } else { "s" } + ); + + let warnings = plan.warnings.clone(); + + Ok(ExecuteRecipePrepared { + run_id: Uuid::new_v4().to_string(), + route, + plan, + summary, + warnings, + }) +} diff --git a/src-tauri/src/recipe_executor_tests.rs b/src-tauri/src/recipe_executor_tests.rs new file mode 100644 index 00000000..ced412f1 --- /dev/null +++ b/src-tauri/src/recipe_executor_tests.rs @@ -0,0 +1,398 @@ +use serde_json::{json, Value}; + +use crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND; +use crate::execution_spec::{ + ExecutionAction, ExecutionCapabilities, ExecutionMetadata, ExecutionResourceClaim, + ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, +}; +use crate::recipe_executor::{ + build_cleanup_commands, build_runtime_artifacts, execute_recipe, materialize_execution_plan, + route_execution, ExecuteRecipeRequest, +}; +use crate::recipe_store::Artifact; + +fn sample_target(kind: &str) -> Value { + match kind { + "remote" => json!({ + "kind": "remote", + "hostId": "ssh:prod-a", + }), + _ => json!({ + "kind": "local", + }), + } +} + +fn sample_job_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-health-check".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { kind: "job".into() }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("openclaw-gateway".into()), + target: None, + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "command": ["openclaw", "doctor", "run"], + }), + actions: vec![ExecutionAction { + kind: Some("job".into()), + name: Some("Run doctor".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + }), + }], + outputs: vec![], + } +} + +fn sample_schedule_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-reconcile".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "schedule".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("schedule/hourly".into()), + target: Some("job/hourly-reconcile".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "schedule": { + "id": "schedule/hourly", + "onCalendar": "hourly", + }, + "job": { + "command": ["openclaw", "doctor", "run"], + } + }), + actions: vec![ExecutionAction { + kind: Some("schedule".into()), + name: Some("Run hourly reconcile".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + "onCalendar": "hourly", + }), + }], + outputs: vec![], + } +} + +fn sample_execution_request() -> ExecuteRecipeRequest { + ExecuteRecipeRequest { + spec: sample_job_spec(), + source_origin: None, + source_text: None, + workspace_slug: None, + } +} + +fn sample_attachment_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("gateway-env".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "attachment".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("openclaw-gateway".into()), + target: Some("openclaw-gateway.service".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "systemdDropIn": { + "unit": "openclaw-gateway.service", + "name": "10-channel.conf", + "content": "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord\n", + }, + "envPatch": { + "OPENCLAW_CHANNEL": "discord", + } + }), + actions: vec![ExecutionAction { + kind: Some("attachment".into()), + name: Some("Apply gateway env".into()), + args: json!({}), + }], + outputs: vec![], + } +} + +fn sample_action_recipe_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("discord-channel-persona".into()), + digest: None, + }, + source: json!({ + "recipeId": "discord-channel-persona", + "recipeVersion": "1.0.0", + }), + target: json!({ "kind": "local" }), + execution: ExecutionTarget { kind: "job".into() }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["config.write".into()], + }, + resources: ExecutionResources::default(), + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": 1, + }), + actions: vec![ExecutionAction { + kind: Some("config_patch".into()), + name: Some("Set channel persona".into()), + args: json!({ + "patch": { + "channels": { + "discord": { + "guilds": { + "guild-1": { + "channels": { + "channel-1": { + "systemPrompt": "Keep answers concise" + } + } + } + } + } + } + } + }), + }], + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": "discord-channel-persona", + })], + } +} + +#[test] +fn job_spec_materializes_to_systemd_run_command() { + let spec = sample_job_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize execution plan"); + + assert!(plan + .commands + .iter() + .any(|cmd| cmd.join(" ").contains("systemd-run"))); +} + +#[test] +fn schedule_spec_references_job_launch_ref() { + let spec = sample_schedule_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize execution plan"); + + assert!(plan + .resources + .iter() + .any(|ref_id| ref_id == "schedule/hourly")); +} + +#[test] +fn local_target_uses_local_runner() { + let route = route_execution(&sample_target("local")).expect("route execution"); + + assert_eq!(route.runner, "local"); +} + +#[test] +fn remote_target_uses_remote_ssh_runner() { + let route = route_execution(&sample_target("remote")).expect("route execution"); + + assert_eq!(route.runner, "remote_ssh"); +} + +#[test] +fn execute_recipe_returns_run_id_and_summary() { + let result = execute_recipe(sample_execution_request()).expect("execute recipe"); + + assert!(!result.run_id.is_empty()); + assert!(!result.summary.is_empty()); +} + +#[test] +fn action_recipe_spec_can_prepare_without_command_payload() { + let result = execute_recipe(ExecuteRecipeRequest { + spec: sample_action_recipe_spec(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare action recipe execution"); + + assert!(!result.run_id.is_empty()); + assert!(result.summary.contains("discord-channel-persona")); +} + +#[test] +fn attachment_spec_materializes_dropin_write_and_daemon_reload() { + let spec = sample_attachment_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize attachment execution plan"); + + assert_eq!( + plan.commands[0], + vec![ + INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.to_string(), + "openclaw-gateway.service".to_string(), + "10-channel.conf".to_string(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord\n".to_string(), + ] + ); + assert!(plan.commands.iter().any(|command| { + command + == &vec![ + INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.to_string(), + "openclaw-gateway.service".to_string(), + "90-clawpal-env-gateway-env.conf".to_string(), + "[Service]\nEnvironment=\"OPENCLAW_CHANNEL=discord\"\n".to_string(), + ] + })); + assert!(plan.commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + })); +} + +#[test] +fn schedule_execution_builds_unit_and_timer_artifacts() { + let spec = sample_schedule_spec(); + let prepared = execute_recipe(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare schedule execution"); + + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts.iter().any( + |artifact| artifact.kind == "systemdUnit" && artifact.label == prepared.plan.unit_name + )); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdTimer")); +} + +#[test] +fn attachment_execution_builds_dropin_and_reload_artifacts() { + let spec = sample_attachment_spec(); + let prepared = execute_recipe(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare attachment execution"); + + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDropIn" + && artifact.path.as_deref() + == Some("~/.config/systemd/user/openclaw-gateway.service.d/10-channel.conf"))); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDropIn" + && artifact.path.as_deref() + == Some("~/.config/systemd/user/openclaw-gateway.service.d/90-clawpal-env-gateway-env.conf"))); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDaemonReload")); +} + +#[test] +fn cleanup_commands_stop_and_reset_failed_for_systemd_artifacts() { + let commands = build_cleanup_commands(&[ + Artifact { + id: "run_01:unit".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly".into(), + path: Some("clawpal-job-hourly".into()), + }, + Artifact { + id: "run_01:timer".into(), + kind: "systemdTimer".into(), + label: "clawpal-job-hourly.timer".into(), + path: Some("clawpal-job-hourly.timer".into()), + }, + ]); + + assert_eq!( + commands, + vec![ + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("stop"), + String::from("clawpal-job-hourly"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("reset-failed"), + String::from("clawpal-job-hourly"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("stop"), + String::from("clawpal-job-hourly.timer"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("reset-failed"), + String::from("clawpal-job-hourly.timer"), + ], + ] + ); +} diff --git a/src-tauri/src/recipe_planner.rs b/src-tauri/src/recipe_planner.rs new file mode 100644 index 00000000..c58a23bb --- /dev/null +++ b/src-tauri/src/recipe_planner.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::execution_spec::{ExecutionResourceClaim, ExecutionSpec}; +use crate::recipe::{load_recipes_from_source_text, step_references_empty_param, Recipe}; +use crate::recipe_adapter::compile_recipe_to_spec; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipePlanSummary { + pub recipe_id: String, + pub recipe_name: String, + pub execution_kind: String, + pub action_count: usize, + pub skipped_step_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipePlan { + pub summary: RecipePlanSummary, + pub used_capabilities: Vec, + pub concrete_claims: Vec, + pub execution_spec_digest: String, + pub execution_spec: ExecutionSpec, + pub warnings: Vec, +} + +pub fn build_recipe_plan( + recipe: &Recipe, + params: &Map, +) -> Result { + let execution_spec = compile_recipe_to_spec(recipe, params)?; + let skipped_step_count = recipe + .steps + .iter() + .filter(|step| step_references_empty_param(step, params)) + .count(); + + let mut warnings = Vec::new(); + if skipped_step_count > 0 { + warnings.push(format!( + "{} optional step(s) will be skipped because their parameters are empty.", + skipped_step_count + )); + } + let digest_source = serde_json::to_vec(&execution_spec).map_err(|error| error.to_string())?; + let execution_spec_digest = Uuid::new_v5(&Uuid::NAMESPACE_OID, &digest_source).to_string(); + + Ok(RecipePlan { + summary: RecipePlanSummary { + recipe_id: recipe.id.clone(), + recipe_name: recipe.name.clone(), + execution_kind: execution_spec.execution.kind.clone(), + action_count: execution_spec.actions.len(), + skipped_step_count, + }, + used_capabilities: execution_spec.capabilities.used_capabilities.clone(), + concrete_claims: execution_spec.resources.claims.clone(), + execution_spec_digest, + execution_spec, + warnings, + }) +} + +pub fn build_recipe_plan_from_source_text( + recipe_id: &str, + params: &Map, + source_text: &str, +) -> Result { + let recipe = load_recipes_from_source_text(source_text)? + .into_iter() + .find(|recipe| recipe.id == recipe_id) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + build_recipe_plan(&recipe, params) +} diff --git a/src-tauri/src/recipe_planner_tests.rs b/src-tauri/src/recipe_planner_tests.rs new file mode 100644 index 00000000..b39e60bf --- /dev/null +++ b/src-tauri/src/recipe_planner_tests.rs @@ -0,0 +1,97 @@ +use serde_json::{Map, Value}; + +use crate::recipe::{builtin_recipes, load_recipes_from_source_text}; +use crate::recipe_adapter::export_recipe_source; +use crate::recipe_planner::{build_recipe_plan, build_recipe_plan_from_source_text}; + +fn sample_inputs() -> Map { + let mut params = Map::new(); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert( + "persona".into(), + Value::String("Keep answers concise".into()), + ); + params +} + +#[test] +fn plan_recipe_returns_capabilities_claims_and_digest() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert!(!plan.used_capabilities.is_empty()); + assert!(!plan.concrete_claims.is_empty()); + assert!(!plan.execution_spec_digest.is_empty()); +} + +#[test] +fn plan_recipe_includes_execution_spec_for_executor_bridge() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert_eq!(plan.execution_spec.kind, "ExecutionSpec"); + assert!(!plan.execution_spec.actions.is_empty()); +} + +#[test] +fn plan_recipe_does_not_emit_legacy_bridge_warning() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert!(plan + .warnings + .iter() + .all(|warning| !warning.to_ascii_lowercase().contains("legacy"))); +} + +#[test] +fn plan_recipe_skips_optional_steps_from_structured_template() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "dedicated-channel-agent") + .expect("builtin recipe"); + let mut params = sample_inputs(); + params.insert("agent_id".into(), Value::String("bot-alpha".into())); + params.insert("model".into(), Value::String("__default__".into())); + params.insert("independent".into(), Value::String("true".into())); + params.insert("name".into(), Value::String(String::new())); + params.insert("emoji".into(), Value::String(String::new())); + params.insert("persona".into(), Value::String(String::new())); + + let plan = build_recipe_plan(&recipe, ¶ms).expect("build plan"); + + assert_eq!(plan.summary.skipped_step_count, 2); + assert_eq!(plan.summary.action_count, 2); + assert_eq!(plan.execution_spec.actions.len(), 2); +} + +#[test] +fn plan_recipe_source_uses_unsaved_draft_text() { + let recipe = builtin_recipes() + .into_iter() + .find(|recipe| recipe.id == "discord-channel-persona") + .expect("builtin recipe"); + let source = export_recipe_source(&recipe).expect("export source"); + let recipes = load_recipes_from_source_text(&source).expect("parse source"); + + let plan = + build_recipe_plan_from_source_text("discord-channel-persona", &sample_inputs(), &source) + .expect("build plan from source"); + + assert_eq!(recipes.len(), 1); + assert_eq!(plan.summary.recipe_id, "discord-channel-persona"); + assert_eq!(plan.execution_spec.kind, "ExecutionSpec"); +} diff --git a/src-tauri/src/recipe_runtime/mod.rs b/src-tauri/src/recipe_runtime/mod.rs new file mode 100644 index 00000000..ef587f6d --- /dev/null +++ b/src-tauri/src/recipe_runtime/mod.rs @@ -0,0 +1 @@ +pub mod systemd; diff --git a/src-tauri/src/recipe_runtime/systemd.rs b/src-tauri/src/recipe_runtime/systemd.rs new file mode 100644 index 00000000..ea96a33d --- /dev/null +++ b/src-tauri/src/recipe_runtime/systemd.rs @@ -0,0 +1,420 @@ +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::execution_spec::ExecutionSpec; + +#[derive(Debug, Clone, Default)] +pub struct SystemdRuntimePlan { + pub unit_name: String, + pub commands: Vec>, + pub resources: Vec, + pub warnings: Vec, +} + +pub fn materialize_job(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = job_unit_name(spec); + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command(&unit_name, &command, None)], + resources: collect_resource_refs(spec), + warnings: Vec::new(), + }) +} + +pub fn materialize_service(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = service_unit_name(spec); + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command( + &unit_name, + &command, + Some(&["--property=Restart=always", "--property=RestartSec=5s"]), + )], + resources: collect_resource_refs(spec), + warnings: Vec::new(), + }) +} + +pub fn materialize_schedule(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = job_unit_name(spec); + let on_calendar = extract_schedule(spec) + .as_deref() + .ok_or_else(|| "schedule spec is missing desired_state.schedule.onCalendar".to_string())? + .to_string(); + + let mut resources = collect_resource_refs(spec); + let launch_ref = format!("job/{}", sanitize_unit_fragment(spec_name(spec))); + if !resources.iter().any(|resource| resource == &launch_ref) { + resources.push(launch_ref); + } + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command( + &unit_name, + &command, + Some(&[ + "--timer-property=Persistent=true", + &format!("--on-calendar={}", on_calendar), + ]), + )], + resources, + warnings: Vec::new(), + }) +} + +pub fn materialize_attachment(spec: &ExecutionSpec) -> Result { + let unit_name = attachment_unit_name(spec); + let mut commands = Vec::new(); + let mut warnings = Vec::new(); + let mut needs_daemon_reload = false; + + if let Some(drop_in) = spec + .desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + { + let target = drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let name = drop_in + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let content = extract_drop_in_content(drop_in); + let missing_target = target.is_none(); + let missing_name = name.is_none(); + let missing_content = content.is_none(); + + match (target, name, content) { + (Some(target), Some(name), Some(content)) => { + commands.push(vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + target.to_string(), + name.to_string(), + content, + ]); + needs_daemon_reload = true; + } + _ => { + let mut missing = Vec::new(); + if missing_target { + missing.push("unit/target"); + } + if missing_name { + missing.push("name"); + } + if missing_content { + missing.push("content"); + } + warnings.push(format!( + "attachment systemdDropIn is missing {}", + missing.join(", ") + )); + } + } + } + + match ( + attachment_target_unit(spec), + render_env_patch_dropin_content(spec), + ) { + (Some(target), Some(content)) => { + commands.push(vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + target, + env_patch_dropin_name(spec), + content, + ]); + needs_daemon_reload = true; + } + (None, Some(_)) => warnings.push( + "attachment envPatch is missing a target unit in systemdDropIn.unit/target or service claim target" + .into(), + ), + _ => {} + } + + if needs_daemon_reload { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + + if commands.is_empty() { + warnings.push( + "attachment spec materialized without concrete systemdDropIn/envPatch operations" + .into(), + ); + } + + Ok(SystemdRuntimePlan { + unit_name, + commands, + resources: collect_resource_refs(spec), + warnings, + }) +} + +fn extract_drop_in_content(drop_in: &serde_json::Map) -> Option { + ["content", "contents", "text", "body"] + .iter() + .find_map(|key| { + drop_in + .get(*key) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .filter(|value| !value.trim().is_empty()) + }) +} + +pub fn attachment_target_unit(spec: &ExecutionSpec) -> Option { + spec.desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + .and_then(|drop_in| { + drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .or_else(|| { + spec.resources + .claims + .iter() + .find(|claim| claim.kind == "service") + .and_then(|claim| claim.target.as_deref().or(claim.id.as_deref())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + }) +} + +pub fn env_patch_dropin_name(spec: &ExecutionSpec) -> String { + format!( + "90-clawpal-env-{}.conf", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +pub fn env_patch_dropin_path(spec: &ExecutionSpec) -> Option { + attachment_target_unit(spec).map(|target| { + format!( + "~/.config/systemd/user/{}.d/{}", + target, + env_patch_dropin_name(spec) + ) + }) +} + +pub fn render_env_patch_dropin_content(spec: &ExecutionSpec) -> Option { + let patch = spec + .desired_state + .get("envPatch") + .and_then(Value::as_object)?; + let mut values = BTreeMap::new(); + + for (key, value) in patch { + let trimmed_key = key.trim(); + if trimmed_key.is_empty() { + continue; + } + let rendered = match value { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => flag.to_string(), + Value::Null => String::new(), + _ => continue, + }; + values.insert(trimmed_key.to_string(), rendered); + } + + if values.is_empty() { + return None; + } + + let mut content = String::from("[Service]\n"); + for (key, value) in values { + content.push_str("Environment=\""); + content.push_str(&escape_systemd_environment_assignment(&key, &value)); + content.push_str("\"\n"); + } + Some(content) +} + +fn escape_systemd_environment_assignment(key: &str, value: &str) -> String { + format!( + "{}={}", + key, + value.replace('\\', "\\\\").replace('"', "\\\"") + ) +} + +fn build_systemd_run_command( + unit_name: &str, + command: &[String], + extra_flags: Option<&[&str]>, +) -> Vec { + let mut cmd = vec![ + "systemd-run".into(), + format!("--unit={}", unit_name), + "--collect".into(), + "--service-type=exec".into(), + ]; + if let Some(flags) = extra_flags { + cmd.extend(flags.iter().map(|flag| flag.to_string())); + } + cmd.push("--".into()); + cmd.extend(command.iter().cloned()); + cmd +} + +fn collect_resource_refs(spec: &ExecutionSpec) -> Vec { + let mut resources = Vec::new(); + + for claim in &spec.resources.claims { + if let Some(id) = &claim.id { + push_unique(&mut resources, id.clone()); + } + if let Some(target) = &claim.target { + push_unique(&mut resources, target.clone()); + } + if let Some(path) = &claim.path { + push_unique(&mut resources, path.clone()); + } + } + + if let Some(schedule_id) = spec + .desired_state + .get("schedule") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + { + push_unique(&mut resources, schedule_id.to_string()); + } + + resources +} + +fn extract_command(spec: &ExecutionSpec) -> Result, String> { + if let Some(command) = extract_command_from_value(spec.desired_state.get("command")) { + return Ok(command); + } + if let Some(command) = spec + .desired_state + .get("job") + .and_then(|value| value.get("command")) + .and_then(|value| extract_command_from_value(Some(value))) + { + return Ok(command); + } + for action in &spec.actions { + if let Some(command) = action + .args + .get("command") + .and_then(|value| extract_command_from_value(Some(value))) + { + return Ok(command); + } + } + + Err("execution spec is missing a concrete command payload".into()) +} + +fn extract_command_from_value(value: Option<&Value>) -> Option> { + value + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.as_str().map(|text| text.to_string())) + .collect::>() + }) + .filter(|parts| !parts.is_empty()) +} + +fn extract_schedule(spec: &ExecutionSpec) -> Option { + spec.desired_state + .get("schedule") + .and_then(|value| value.get("onCalendar")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .or_else(|| { + spec.actions.iter().find_map(|action| { + action + .args + .get("onCalendar") + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + }) +} + +fn job_unit_name(spec: &ExecutionSpec) -> String { + format!("clawpal-job-{}", sanitize_unit_fragment(spec_name(spec))) +} + +fn service_unit_name(spec: &ExecutionSpec) -> String { + format!( + "clawpal-service-{}", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +fn attachment_unit_name(spec: &ExecutionSpec) -> String { + format!( + "clawpal-attachment-{}", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +fn spec_name(spec: &ExecutionSpec) -> &str { + spec.metadata + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("spec") +} + +fn sanitize_unit_fragment(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let collapsed = sanitized + .split('-') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + "spec".into() + } else { + collapsed + } +} + +fn push_unique(values: &mut Vec, next: String) { + if !values.iter().any(|existing| existing == &next) { + values.push(next); + } +} diff --git a/src-tauri/src/recipe_store.rs b/src-tauri/src/recipe_store.rs new file mode 100644 index 00000000..dfdda085 --- /dev/null +++ b/src-tauri/src/recipe_store.rs @@ -0,0 +1,197 @@ +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::models::resolve_paths; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ResourceClaim { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + pub id: String, + pub kind: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Run { + pub id: String, + pub instance_id: String, + pub recipe_id: String, + pub execution_kind: String, + pub runner: String, + pub status: String, + pub summary: String, + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub resource_claims: Vec, + #[serde(default)] + pub warnings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_origin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecipeInstance { + pub id: String, + pub recipe_id: String, + pub execution_kind: String, + pub runner: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_run_id: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct RecipeRuntimeIndex { + #[serde(default)] + instances: Vec, + #[serde(default)] + runs: Vec, +} + +#[derive(Debug, Clone)] +pub struct RecipeStore { + runtime_dir: PathBuf, + index_path: PathBuf, +} + +impl RecipeStore { + pub fn new(runtime_dir: PathBuf) -> Self { + Self { + index_path: runtime_dir.join("index.json"), + runtime_dir, + } + } + + pub fn from_resolved_paths() -> Self { + Self::new(resolve_paths().recipe_runtime_dir) + } + + pub fn for_test() -> Self { + let root = std::env::temp_dir().join(format!("clawpal-recipe-store-{}", Uuid::new_v4())); + Self::new(root) + } + + pub fn record_run(&self, run: Run) -> Result { + fs::create_dir_all(&self.runtime_dir).map_err(|error| error.to_string())?; + + let mut index = self.read_index()?; + let updated_at = run + .finished_at + .clone() + .unwrap_or_else(|| run.started_at.clone()); + + index.runs.retain(|existing| existing.id != run.id); + index.runs.push(run.clone()); + index.runs.sort_by(|left, right| { + right + .started_at + .cmp(&left.started_at) + .then_with(|| right.id.cmp(&left.id)) + }); + + let next_instance = RecipeInstance { + id: run.instance_id.clone(), + recipe_id: run.recipe_id.clone(), + execution_kind: run.execution_kind.clone(), + runner: run.runner.clone(), + status: run.status.clone(), + last_run_id: Some(run.id.clone()), + updated_at, + }; + + index + .instances + .retain(|instance| instance.id != next_instance.id); + index.instances.push(next_instance); + index.instances.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + self.write_index(&index)?; + Ok(run) + } + + pub fn list_runs(&self, instance_id: &str) -> Result, String> { + let index = self.read_index()?; + Ok(index + .runs + .into_iter() + .filter(|run| run.instance_id == instance_id) + .collect()) + } + + pub fn list_all_runs(&self) -> Result, String> { + Ok(self.read_index()?.runs) + } + + pub fn list_instances(&self) -> Result, String> { + Ok(self.read_index()?.instances) + } + + fn read_index(&self) -> Result { + if !self.index_path.exists() { + return Ok(RecipeRuntimeIndex::default()); + } + + let mut file = File::open(&self.index_path).map_err(|error| error.to_string())?; + let mut text = String::new(); + file.read_to_string(&mut text) + .map_err(|error| error.to_string())?; + + if text.trim().is_empty() { + return Ok(RecipeRuntimeIndex::default()); + } + + serde_json::from_str(&text).map_err(|error| error.to_string()) + } + + fn write_index(&self, index: &RecipeRuntimeIndex) -> Result<(), String> { + fs::create_dir_all(&self.runtime_dir).map_err(|error| error.to_string())?; + let text = serde_json::to_string_pretty(index).map_err(|error| error.to_string())?; + atomic_write(&self.index_path, &text) + } +} + +fn atomic_write(path: &Path, text: &str) -> Result<(), String> { + let tmp_path = path.with_extension("tmp"); + { + let mut file = File::create(&tmp_path).map_err(|error| error.to_string())?; + file.write_all(text.as_bytes()) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + } + fs::rename(&tmp_path, path).map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/recipe_store_tests.rs b/src-tauri/src/recipe_store_tests.rs new file mode 100644 index 00000000..6a788d4f --- /dev/null +++ b/src-tauri/src/recipe_store_tests.rs @@ -0,0 +1,92 @@ +use crate::recipe_store::{Artifact, RecipeStore, ResourceClaim, Run}; + +fn sample_run() -> Run { + Run { + id: "run_01".into(), + instance_id: "inst_01".into(), + recipe_id: "discord-channel-persona".into(), + execution_kind: "attachment".into(), + runner: "local".into(), + status: "succeeded".into(), + summary: "Applied persona patch".into(), + started_at: "2026-03-11T10:00:00Z".into(), + finished_at: Some("2026-03-11T10:00:03Z".into()), + artifacts: vec![Artifact { + id: "artifact_01".into(), + kind: "configDiff".into(), + label: "Rendered patch".into(), + path: Some("/tmp/rendered-patch.json".into()), + }], + resource_claims: vec![ResourceClaim { + kind: "path".into(), + id: Some("openclaw.config".into()), + target: None, + path: Some("~/.openclaw/openclaw.json".into()), + }], + warnings: vec![], + source_origin: None, + source_digest: None, + workspace_path: None, + } +} + +fn sample_run_with_source() -> Run { + let mut run = sample_run(); + run.source_origin = Some("draft".into()); + run.source_digest = Some("digest-123".into()); + run.workspace_path = + Some("/Users/chen/.clawpal/recipes/workspace/channel-persona.recipe.json".into()); + run +} + +#[test] +fn record_run_persists_instance_and_artifacts() { + let store = RecipeStore::for_test(); + let run = store.record_run(sample_run()).expect("record run"); + + assert_eq!(store.list_runs("inst_01").expect("list runs")[0].id, run.id); + assert_eq!( + store.list_instances().expect("list instances")[0] + .last_run_id + .as_deref(), + Some(run.id.as_str()) + ); + assert_eq!( + store.list_runs("inst_01").expect("list runs")[0].artifacts[0].id, + "artifact_01" + ); +} + +#[test] +fn list_all_runs_returns_latest_runs() { + let store = RecipeStore::for_test(); + store.record_run(sample_run()).expect("record first run"); + + let mut second_run = sample_run(); + second_run.id = "run_02".into(); + second_run.instance_id = "ssh:prod-a".into(); + second_run.started_at = "2026-03-11T11:00:00Z".into(); + second_run.finished_at = Some("2026-03-11T11:00:05Z".into()); + store.record_run(second_run).expect("record second run"); + + let runs = store.list_all_runs().expect("list all runs"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].id, "run_02"); + assert_eq!(runs[1].id, "run_01"); +} + +#[test] +fn recorded_run_persists_source_digest_and_origin() { + let store = RecipeStore::for_test(); + store + .record_run(sample_run_with_source()) + .expect("record run with source"); + + let stored = store.list_runs("inst_01").expect("list runs"); + assert_eq!(stored[0].source_origin.as_deref(), Some("draft")); + assert_eq!(stored[0].source_digest.as_deref(), Some("digest-123")); + assert!(stored[0] + .workspace_path + .as_deref() + .is_some_and(|path| path.ends_with("channel-persona.recipe.json"))); +} diff --git a/src-tauri/src/recipe_workspace.rs b/src-tauri/src/recipe_workspace.rs new file mode 100644 index 00000000..f4246255 --- /dev/null +++ b/src-tauri/src/recipe_workspace.rs @@ -0,0 +1,153 @@ +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::config_io::write_text; +use crate::models::resolve_paths; + +const WORKSPACE_FILE_SUFFIX: &str = ".recipe.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeWorkspaceEntry { + pub slug: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceSaveResult { + pub slug: String, + pub path: String, +} + +#[derive(Debug, Clone)] +pub struct RecipeWorkspace { + root: PathBuf, +} + +impl RecipeWorkspace { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn from_resolved_paths() -> Self { + let root = resolve_paths() + .clawpal_dir + .join("recipes") + .join("workspace"); + Self::new(root) + } + + pub fn list_entries(&self) -> Result, String> { + if !self.root.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let Some(slug) = file_name.strip_suffix(WORKSPACE_FILE_SUFFIX) else { + continue; + }; + + entries.push(RecipeWorkspaceEntry { + slug: slug.to_string(), + path: path.to_string_lossy().to_string(), + }); + } + + entries.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(entries) + } + + pub fn read_recipe_source(&self, slug: &str) -> Result { + let path = self.path_for_slug(slug)?; + fs::read_to_string(&path) + .map_err(|error| format!("failed to read recipe source '{}': {}", slug, error)) + } + + pub fn resolve_recipe_source_path(&self, raw_slug: &str) -> Result { + self.path_for_slug(raw_slug) + .map(|path| path.to_string_lossy().to_string()) + } + + pub fn save_recipe_source( + &self, + raw_slug: &str, + source: &str, + ) -> Result { + let slug = normalize_slug(raw_slug)?; + let path = self.root.join(format!("{}{}", slug, WORKSPACE_FILE_SUFFIX)); + write_text(&path, source)?; + Ok(RecipeSourceSaveResult { + slug, + path: path.to_string_lossy().to_string(), + }) + } + + pub fn delete_recipe_source(&self, raw_slug: &str) -> Result<(), String> { + let path = self.path_for_slug(raw_slug)?; + if path.exists() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + Ok(()) + } + + fn path_for_slug(&self, raw_slug: &str) -> Result { + let slug = normalize_slug(raw_slug)?; + Ok(self.root.join(format!("{}{}", slug, WORKSPACE_FILE_SUFFIX))) + } +} + +fn normalize_slug(raw_slug: &str) -> Result { + let trimmed = raw_slug.trim(); + if trimmed.is_empty() { + return Err("recipe slug cannot be empty".into()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("recipe slug contains a disallowed path segment".into()); + } + + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + continue; + } + + if matches!(ch, '-' | '_' | ' ') { + if !slug.is_empty() && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + continue; + } + + return Err(format!( + "recipe slug contains unsupported character '{}'", + ch + )); + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { + return Err("recipe slug must contain at least one alphanumeric character".into()); + } + + Ok(slug) +} diff --git a/src-tauri/src/recipe_workspace_tests.rs b/src-tauri/src/recipe_workspace_tests.rs new file mode 100644 index 00000000..25378518 --- /dev/null +++ b/src-tauri/src/recipe_workspace_tests.rs @@ -0,0 +1,125 @@ +use std::fs; +use std::path::PathBuf; + +use uuid::Uuid; + +use crate::recipe_workspace::RecipeWorkspace; + +const SAMPLE_SOURCE: &str = r#"{ + "id": "channel-persona", + "name": "Channel Persona", + "description": "Set a custom persona for a channel", + "version": "1.0.0", + "tags": ["discord", "persona"], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } +}"#; + +struct TempWorkspaceRoot(PathBuf); + +impl TempWorkspaceRoot { + fn path(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for TempWorkspaceRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn temp_workspace_root() -> TempWorkspaceRoot { + let root = std::env::temp_dir().join(format!("clawpal-recipe-workspace-{}", Uuid::new_v4())); + fs::create_dir_all(&root).expect("create temp workspace root"); + TempWorkspaceRoot(root) +} + +#[test] +fn workspace_recipe_save_writes_under_clawpal_recipe_workspace() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + let result = store + .save_recipe_source("channel-persona", SAMPLE_SOURCE) + .expect("save recipe source"); + + assert_eq!(result.slug, "channel-persona"); + assert_eq!( + result.path, + root.path() + .join("channel-persona.recipe.json") + .to_string_lossy() + ); + assert!(root.path().join("channel-persona.recipe.json").exists()); +} + +#[test] +fn workspace_recipe_save_rejects_parent_traversal() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + assert!(store + .save_recipe_source("../escape", SAMPLE_SOURCE) + .is_err()); +} + +#[test] +fn delete_workspace_recipe_removes_saved_file() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + let saved = store + .save_recipe_source("persona", SAMPLE_SOURCE) + .expect("save recipe source"); + + store + .delete_recipe_source(saved.slug.as_str()) + .expect("delete recipe source"); + + assert!(!root.path().join("persona.recipe.json").exists()); +} + +#[test] +fn list_workspace_entries_returns_saved_recipes() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + store + .save_recipe_source("zeta", SAMPLE_SOURCE) + .expect("save zeta"); + store + .save_recipe_source("alpha", SAMPLE_SOURCE) + .expect("save alpha"); + + let entries = store.list_entries().expect("list entries"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].slug, "alpha"); + assert_eq!(entries[1].slug, "zeta"); +} diff --git a/src/App.tsx b/src/App.tsx index de55dd39..f8533ec3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,20 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn, formatBytes } from "@/lib/utils"; import { toast, Toaster } from "sonner"; -import type { ChannelNode, DiscordGuildChannel, DiscoveredInstance, DockerInstance, InstallSession, PrecheckIssue, RegisteredInstance, SshHost, SshTransferStats } from "./lib/types"; +import type { + ChannelNode, + DiscordGuildChannel, + DiscoveredInstance, + DockerInstance, + InstallSession, + PrecheckIssue, + RecipeEditorOrigin, + RecipeStudioDraft, + RecipeSourceOrigin, + RegisteredInstance, + SshHost, + SshTransferStats, +} from "./lib/types"; import { SshFormWidget } from "./components/SshFormWidget"; import { closeWorkspaceTab } from "@/lib/tabWorkspace"; import { @@ -51,6 +64,7 @@ import { buildFriendlySshError, extractErrorText } from "@/lib/sshDiagnostic"; const Home = lazy(() => import("./pages/Home").then((m) => ({ default: m.Home }))); const Recipes = lazy(() => import("./pages/Recipes").then((m) => ({ default: m.Recipes }))); +const RecipeStudio = lazy(() => import("./pages/RecipeStudio").then((m) => ({ default: m.RecipeStudio }))); const Cook = lazy(() => import("./pages/Cook").then((m) => ({ default: m.Cook }))); const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History }))); const Settings = lazy(() => import("./pages/Settings").then((m) => ({ default: m.Settings }))); @@ -66,6 +80,7 @@ const preloadRouteModules = () => import("./pages/Home"), import("./pages/Channels"), import("./pages/Recipes"), + import("./pages/RecipeStudio"), import("./pages/Cron"), import("./pages/Doctor"), import("./pages/OpenclawContext"), @@ -80,7 +95,7 @@ const DEFAULT_DOCKER_OPENCLAW_HOME = "~/.clawpal/docker-local"; const DEFAULT_DOCKER_CLAWPAL_DATA_DIR = "~/.clawpal/docker-local/data"; const DEFAULT_DOCKER_INSTANCE_ID = "docker:local"; -type Route = "home" | "recipes" | "cook" | "history" | "channels" | "cron" | "doctor" | "context" | "orchestrator"; +type Route = "home" | "recipes" | "recipe-studio" | "cook" | "history" | "channels" | "cron" | "doctor" | "context" | "orchestrator"; const INSTANCE_ROUTES: Route[] = ["home", "channels", "recipes", "cron", "doctor", "context", "history"]; const OPEN_TABS_STORAGE_KEY = "clawpal_open_tabs"; const APP_PREFERENCES_CACHE_KEY = buildCacheKey("__global__", "getAppPreferences", []); @@ -159,6 +174,15 @@ export function App() { const [route, setRoute] = useState("home"); const [recipeId, setRecipeId] = useState(null); const [recipeSource, setRecipeSource] = useState(undefined); + const [recipeSourceText, setRecipeSourceText] = useState(undefined); + const [recipeSourceOrigin, setRecipeSourceOrigin] = useState("saved"); + const [recipeSourceWorkspaceSlug, setRecipeSourceWorkspaceSlug] = useState(undefined); + const [recipeEditorRecipeId, setRecipeEditorRecipeId] = useState(null); + const [recipeEditorRecipeName, setRecipeEditorRecipeName] = useState(""); + const [recipeEditorSource, setRecipeEditorSource] = useState(""); + const [recipeEditorOrigin, setRecipeEditorOrigin] = useState("builtin"); + const [recipeEditorWorkspaceSlug, setRecipeEditorWorkspaceSlug] = useState(undefined); + const [cookReturnRoute, setCookReturnRoute] = useState("recipes"); const [channelNodes, setChannelNodes] = useState(null); const [discordGuildChannels, setDiscordGuildChannels] = useState(null); const [channelsLoading, setChannelsLoading] = useState(false); @@ -191,6 +215,14 @@ export function App() { const navigateRoute = useCallback((next: Route) => { startTransition(() => setRoute(next)); }, []); + const openRecipeStudio = useCallback((draft: RecipeStudioDraft) => { + setRecipeEditorRecipeId(draft.recipeId); + setRecipeEditorRecipeName(draft.recipeName); + setRecipeEditorSource(draft.source); + setRecipeEditorOrigin(draft.origin); + setRecipeEditorWorkspaceSlug(draft.workspaceSlug); + navigateRoute("recipe-studio"); + }, [navigateRoute]); const handleEditSsh = useCallback((host: SshHost) => { setEditingSshHost(host); @@ -1671,16 +1703,52 @@ export function App() { onCook={(id, source) => { setRecipeId(id); setRecipeSource(source); + setRecipeSourceText(undefined); + setRecipeSourceOrigin("saved"); + setRecipeSourceWorkspaceSlug(undefined); + setCookReturnRoute("recipes"); + navigateRoute("cook"); + }} + onOpenStudio={openRecipeStudio} + onOpenRuntimeDashboard={() => navigateRoute("orchestrator")} + /> + )} + {!inStart && route === "recipe-studio" && recipeEditorRecipeId && ( + { + setRecipeId(draft.recipeId); + setRecipeSource(undefined); + setRecipeSourceText(draft.source); + setRecipeSourceOrigin("draft"); + setRecipeSourceWorkspaceSlug(draft.workspaceSlug); + setCookReturnRoute("recipe-studio"); + setRecipeEditorRecipeId(draft.recipeId); + setRecipeEditorRecipeName(draft.recipeName); + setRecipeEditorSource(draft.source); + setRecipeEditorOrigin(draft.origin); + setRecipeEditorWorkspaceSlug(draft.workspaceSlug); navigateRoute("cook"); }} + onBack={() => navigateRoute("recipes")} /> )} + {!inStart && route === "recipe-studio" && !recipeEditorRecipeId && ( +

{t("recipeStudio.noRecipeSelected")}

+ )} {!inStart && route === "cook" && recipeId && ( { - navigateRoute("recipes"); + navigateRoute(cookReturnRoute); }} /> )} @@ -1692,7 +1760,12 @@ export function App() { /> )} {!inStart && route === "cron" && } - {!inStart && route === "history" && } + {!inStart && route === "history" && ( + navigateRoute("orchestrator")} + /> + )} {!inStart && route === "doctor" && ( )} diff --git a/src/components/RecipeCard.tsx b/src/components/RecipeCard.tsx index b7d67db4..33ea177c 100644 --- a/src/components/RecipeCard.tsx +++ b/src/components/RecipeCard.tsx @@ -7,10 +7,16 @@ import { Button } from "@/components/ui/button"; export function RecipeCard({ recipe, onCook, + onViewSource, + onEditSource, + onForkToWorkspace, compact, }: { recipe: Recipe; onCook: (id: string) => void; + onViewSource?: (id: string) => void; + onEditSource?: (id: string) => void; + onForkToWorkspace?: (id: string) => void; compact?: boolean; }) { const { t } = useTranslation(); @@ -63,9 +69,26 @@ export function RecipeCard({

- +
+ + {onViewSource && ( + + )} + {onEditSource && ( + + )} + {onForkToWorkspace && ( + + )} +
); diff --git a/src/components/RecipeFormEditor.tsx b/src/components/RecipeFormEditor.tsx new file mode 100644 index 00000000..1756e354 --- /dev/null +++ b/src/components/RecipeFormEditor.tsx @@ -0,0 +1,278 @@ +import { useTranslation } from "react-i18next"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { RecipeEditorModel } from "@/lib/types"; + +function updateArrayItem(items: T[], index: number, nextValue: T): T[] { + return items.map((item, itemIndex) => (itemIndex === index ? nextValue : item)); +} + +export function RecipeFormEditor({ + model, + readOnly, + onChange, +}: { + model: RecipeEditorModel; + readOnly: boolean; + onChange: (nextModel: RecipeEditorModel) => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+
+ + onChange({ ...model, id: event.target.value })} + /> +
+
+ + onChange({ ...model, name: event.target.value })} + /> +
+
+ +