From d5e1a1a51ddc8806c0b3011601cbd4073a524e3a Mon Sep 17 00:00:00 2001 From: Romuald Date: Sun, 17 May 2026 23:00:15 +0200 Subject: [PATCH] fix : WF change the step outputs, standarization, and quick fixes Signed-off-by: Romuald --- .github/workflows/ci-test.yml | 20 + .github/workflows/desktop-build.yml | 68 ++- .gitignore | 4 + CHANGELOG.md | 70 +++ Makefile | 8 + VERSION | 2 +- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/scripts/disc-introspection-mcp.py | 389 +++++++++++++ .../scripts/test_disc_introspection_mcp.py | 243 ++++++++ backend/src/api/workflows.rs | 479 +++++++++++++++- backend/src/api_tests.rs | 68 +++ backend/src/core/skills.rs | 99 ++++ backend/src/models/setup.rs | 13 +- backend/src/models/workflows.rs | 110 +++- backend/src/skills/qp-improver.md | 27 + backend/src/skills/workflow-architect.md | 95 +++- backend/src/workflows/api_call_executor.rs | 120 +++- backend/src/workflows/batch_apicall_step.rs | 31 +- backend/src/workflows/batch_step.rs | 42 +- backend/src/workflows/big_ticket_template.rs | 5 + backend/src/workflows/json_data_step.rs | 34 +- backend/src/workflows/mod.rs | 1 + backend/src/workflows/notify_step.rs | 30 +- backend/src/workflows/step_output_format.rs | 224 ++++++++ backend/src/workflows/template.rs | 534 ++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 4 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- docs/AGENTS.md | 42 +- frontend/e2e/pages/WorkflowWizardPage.ts | 125 +++- .../wizard-create-button-validation.spec.ts | 12 +- frontend/e2e/specs/wizard-presets.spec.ts | 36 +- frontend/e2e/specs/wizard-save-error.spec.ts | 11 +- frontend/package.json | 2 +- frontend/src/components/ChatHeader.tsx | 24 + frontend/src/components/DiscussionSidebar.tsx | 11 +- frontend/src/components/SwipeableDiscItem.tsx | 11 +- .../workflows/WorkflowQuickStartPicker.tsx | 277 +++++++++ .../components/workflows/WorkflowWizard.tsx | 199 +++---- .../WorkflowQuickStartPicker.test.tsx | 257 +++++++++ frontend/src/lib/__tests__/api.test.ts | 62 ++ .../__tests__/workflow-quick-start.test.ts | 327 +++++++++++ frontend/src/lib/api.ts | 14 +- frontend/src/lib/i18n.ts | 99 +++- frontend/src/lib/workflow-quick-start.ts | 253 +++++++++ frontend/src/pages/DiscussionsPage.css | 28 + frontend/src/pages/WorkflowsPage.css | 272 +++++++++ frontend/src/pages/WorkflowsPage.tsx | 25 +- frontend/tsconfig.tsbuildinfo | 2 +- 51 files changed, 4477 insertions(+), 342 deletions(-) create mode 100644 backend/scripts/test_disc_introspection_mcp.py create mode 100644 backend/src/workflows/step_output_format.rs create mode 100644 frontend/src/components/workflows/WorkflowQuickStartPicker.tsx create mode 100644 frontend/src/components/workflows/__tests__/WorkflowQuickStartPicker.test.tsx create mode 100644 frontend/src/lib/__tests__/workflow-quick-start.test.ts create mode 100644 frontend/src/lib/workflow-quick-start.ts diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 1cf0be01..e427314e 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -158,6 +158,26 @@ jobs: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + # ── Python helper tests ──────────────────────────────────────────────── + # `backend/scripts/disc-introspection-mcp.py` powers the kronn-internal + # MCP server (5+ agent CLIs invoke it). Its 0.8.5 auto-inherit helpers + # have no Rust coverage — these unittest cases pin the contract: + # cache behaviour, missing-env handling, idempotency-collision guard + # on `source_session_id`. Stdlib only — zero dev deps, sub-second run. + test-python: + if: github.event.label.name == 'ci-test' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run MCP helper tests + run: python3 -m unittest discover -s backend/scripts -p 'test_*.py' -v + test-frontend: if: github.event.label.name == 'ci-test' runs-on: ubuntu-latest diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index bb593f01..d88569e2 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -144,21 +144,37 @@ jobs: # write the bundle under a different target prefix than we # expect (happened on macos-15 / x86_64-apple-darwin where the # bundle landed under `release/bundle` without the target prefix). - BUNDLE_DIR="desktop/src-tauri/target/${{ matrix.target }}/release/bundle" - if [ ! -d "$BUNDLE_DIR" ]; then - echo "Bundle dir not found at $BUNDLE_DIR — searching alternative paths:" - # Fall back to the unprefixed `target/release/bundle` layout - # that Tauri uses when `--target` is the host-default triple. - ALT_DIR="desktop/src-tauri/target/release/bundle" - if [ -d "$ALT_DIR" ]; then - echo " found at $ALT_DIR — using it" - BUNDLE_DIR="$ALT_DIR" - else - echo " no alternative bundle dir either; listing target tree for diagnostics:" - ls -la "desktop/src-tauri/target/" 2>/dev/null || echo " (target/ missing entirely)" - echo "Skipping ad-hoc sign — no bundle to sign." - exit 0 + # Bundle path resolution. Two roots possible: + # - LEGACY (pre-2026-05-15): `desktop/src-tauri/target/...` + # - SHARED (since `.cargo/config.toml` set `target-dir = "target"` + # at the repo root to mutualise tokio/serde/reqwest between + # backend/ and desktop/src-tauri/ → ~40-50% local cache savings, + # cf. [[feedback_rust_target_bloat]]): `target/...` (project root). + # Two triple suffixes possible: `/release/bundle` when + # cross-compiling, plain `release/bundle` when host==target. + # Check all 4 combinations, use the first that exists. + BUNDLE_CANDIDATES=( + "desktop/src-tauri/target/${{ matrix.target }}/release/bundle" + "desktop/src-tauri/target/release/bundle" + "target/${{ matrix.target }}/release/bundle" + "target/release/bundle" + ) + BUNDLE_DIR="" + for c in "${BUNDLE_CANDIDATES[@]}"; do + if [ -d "$c" ]; then + echo "Bundle dir found at $c" + BUNDLE_DIR="$c" + break fi + done + if [ -z "$BUNDLE_DIR" ]; then + echo "No bundle dir found in any of:" + printf ' - %s\n' "${BUNDLE_CANDIDATES[@]}" + echo "Diagnostics:" + ls -la "target/" 2>/dev/null || echo " (root target/ missing)" + ls -la "desktop/src-tauri/target/" 2>/dev/null || echo " (desktop/src-tauri/target/ missing)" + echo "Skipping ad-hoc sign — no bundle to sign." + exit 0 fi APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 3 -name "*.app" 2>/dev/null | head -1) if [ -n "$APP_PATH" ] && [ -z "$_APPLE_CERTIFICATE" ]; then @@ -173,6 +189,14 @@ jobs: # ─── Upload artifacts ───────────────────────────────────────── + # Path resolution note (2026-05-18 fix): since `.cargo/config.toml` + # sets `target-dir = "target"` at the repo root, builds now land in + # `/target/...` instead of `/desktop/src-tauri/target/...`. The + # globs below carry BOTH roots so the workflow works whether the + # build was emitted under the legacy or shared target dir. + # `upload-artifact` silently skips missing paths so listing both + # is safe. Cf. [[feedback_rust_target_bloat]]. + - name: Upload Windows artifacts if: matrix.os == 'windows-latest' uses: actions/upload-artifact@v4 @@ -181,21 +205,23 @@ jobs: path: | desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi - name: Upload macOS artifacts if: startsWith(matrix.os, 'macos') uses: actions/upload-artifact@v4 with: name: kronn-${{ matrix.label }} - # Two candidate paths: tauri-bundler usually writes under the - # target-prefixed dir, but on macos-15 / x86_64-apple-darwin - # (cross-compile on arm64 host) it was observed writing to the - # unprefixed `target/release/bundle/dmg`. Globbing both — only - # the one that exists will produce files; the other is silently - # skipped by upload-artifact. + # Four candidate paths: legacy vs shared target root × prefixed + # triple vs unprefixed (Tauri uses the unprefixed `release/bundle` + # when --target is the host-default triple, e.g. macos-15 / + # x86_64-apple-darwin cross-compile on arm64 host). path: | desktop/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg desktop/src-tauri/target/release/bundle/dmg/*.dmg + target/${{ matrix.target }}/release/bundle/dmg/*.dmg + target/release/bundle/dmg/*.dmg if-no-files-found: error - name: Upload Linux artifacts @@ -206,6 +232,8 @@ jobs: path: | desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage # ─── Create GitHub Release on tag ───────────────────────────────── diff --git a/.gitignore b/.gitignore index 60867ab5..f506aca1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ desktop/src-tauri/icons/ios/ desktop/src-tauri/icons/appx/ desktop/node_modules/ +# Python — bytecode cache (regenerated on every MCP script run) +__pycache__/ +*.pyc + # Node frontend/node_modules/ frontend/node_modules_old/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d260816..8a98fc81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,76 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.8.5] - 2026-05-17 + +**Inter-step plumbing homogénéisée + wizard refactor + 5 fixes critiques découverts en dogfooding AutoPilot.** + +Release "irréprochable sur les workflows" — chaque step type émet désormais EXACTEMENT le même envelope canonique (markers `---STEP_OUTPUT---` + `[SIGNAL: …]`), la stratégie inter-step ne dépend plus du type producteur. 4 bugs critiques de plumbing (manual trigger var injection silencieusement droppée, endpoint `{{var}}` non-interpolée, `WorkflowStep` ApiCall serde required-without-default, body 422 swallowé côté frontend) trouvés et corrigés via le dogfooding sur EW-7247 + Ticket Autopilot sur DOCROMS_WEB. Pulled forward de 0.9.0 parce que le risque "un workflow user qui casse silencieusement" était inacceptable. + +### Added + +- **Canonical Kronn step-output envelope** (`backend/src/workflows/step_output_format.rs` + 6 unit tests). Single source of truth for ALL envelope-producing step types: `[optional human prefix]\n---STEP_OUTPUT---\n{data, status, summary}\n---END_STEP_OUTPUT---\n[SIGNAL: ]\n[SIGNAL: ]`. Wired into `api_call_executor` (was bare JSON + signal), `json_data_step` (was bare JSON, no signal), `notify_step` (was bare JSON, no signal), `batch_step::build_structured_output` + `batch_apicall_step` (was bare JSON, partial signals). `exec_step` already canonical — left alone. Gate + Agent FreeText stay envelope-less by design. Cf. [[project_step_output_homogenisation_0_9_0]]. + +- **Cross-step output transmission test matrix** (`backend/src/workflows/template.rs::cross_step_transmission`) — 17 dedicated tests pinning that EVERY step type produces / EVERY consumer can read the canonical envelope. Per-step-type tests (`json_data_exposes_data_summary_status_and_nested_fields`, `apicall_exposes_nested_path_into_real_jira_payload`, `exec_exposes_exit_code_and_stdout_excerpt`, `agent_structured_exposes_typed_manifest_fields`, `notify_exposes_http_metadata_to_downstream_steps`, `batch_exposes_counters_and_discussion_ids`, `gate_exposes_only_output_no_envelope`, `agent_freetext_exposes_only_output_no_data_envelope`) + 7 canonical source→consumer pairs (ApiCall→Agent, JsonData→Agent, Agent→Exec, Exec→Agent, ApiCall→Notify, Gate→following, Batch→Agent) + 1 catch-all `canonical_keys_present_for_every_envelope_producing_step_type` that iterates the full matrix to catch any single-step regression + 1 dedicated `legacy_bare_json_envelope_still_extracts_correctly` for back-compat with pre-0.8.5 run records in DB. + +- **Wizard `WorkflowQuickStartPicker`** (`frontend/src/components/workflows/WorkflowQuickStartPicker.tsx` + `lib/workflow-quick-start.ts` adapters + 31 tests across `workflow-quick-start.test.ts` and `WorkflowQuickStartPicker.test.tsx`) — unified entry point at the top of wizard step 0. Replaces three previously separate UI sections (STARTER_TEMPLATES buttons at top, project suggestions toggle/panel at top, v0.7 preset bandeau buried in Advanced→Step 2). Searchable + sortable + filter chips (complexity × source); applicable-state greying with explanatory tooltip. Disabled until the workflow name is filled (gates avoid the "selected template then bounced back to step 0" UX surprise). Cf. [[project_linked_repos_picker_0_8_5]] for the next 0.8.5 picker work. + +- **Manual trigger variable injection: full safety extraction** (`backend/src/api/workflows.rs::build_manual_trigger_obj` + 9 dedicated tests). Pre-fix `POST /api/workflows/:id/trigger` only forwarded variables that appeared in `wf.variables` (the declared list), silently dropping any auto-detected `{{var}}` the frontend launch modal had asked the user to fill — so workflows fired with literal `{{issue_key}}` strings in step prompts → URL-encoded `%7B%7Bissue_key%7D%7D` → 404 from Jira. Caught during EW-7247 AutoPilot dogfooding 2026-05-17. Now accepts EVERY provided variable, with a conservative safety filter (`is_safe_trigger_var_name` — ASCII word chars + dot, ≤ 64 chars). Reserved keys (`type`, `triggered_at`) cannot be spoofed by the payload — pinned by `build_manual_trigger_obj_reserved_keys_cannot_be_spoofed_by_user`. Critical regression coverage — pre-fix this path had ZERO test coverage. + +- **Endpoint `{{var}}` interpolation in ApiCall steps** (`backend/src/workflows/api_call_executor.rs:131` + 4 tests in `endpoint_double_brace_var_*`). Pre-fix the endpoint only honoured single-brace `{key}` (resolved against `step.api_path_params`), masking and restoring any `{{...}}` runs verbatim. Users who wrote `/rest/api/3/issue/{{issue_key}}` directly (the natural shape the AI helper suggests) got a URL-encoded literal and a confusing Jira 404. Now `ctx.render()` runs FIRST so `{{issue_key}}` → `EW-7247`, then `resolve_path_params` does its percent-encoded `{key}` pass on the result. Mixed forms `/rest/api/{{base}}/issue/{issue_id}` work correctly. + +- **MCP read tools — `workflow_list` / `qp_list` / `qa_list` / `mcp_list`** (`backend/scripts/disc-introspection-mcp.py` + workflow-architect + qp-improver skills) — agents can now LIST existing artifacts before creating duplicates. Compact JSON payload (no full bodies — the agent calls `GET //` for details when needed). Skills now teach "always list before you create" so the agent reuses existing QPs / QAs via `quick_prompt_id` / `quick_api_id` instead of inlining duplicate prompts. Live-tested: `workflow_list` returns the user's 10 workflows with `enabled` + `step_count` + `last_run_status`, `qp_list` surfaces variable names + skill bindings, `mcp_list` enumerates both configured plugin instances + REGISTRY servers with `api_spec` so the agent can pick `api_plugin_slug` deterministically. + +- **MCP auto-inherits `project_id` + `source_agent` from current discussion** (`backend/scripts/disc-introspection-mcp.py::_current_disc_meta`) — pre-fix every agent-created disc / workflow / QP landed in "Général" because the agent didn't know to look up the parent disc's project, AND agent-created discs were visually indistinguishable from UI-created ones because `source_agent` (the 0.8.4 cross-agent memory field that drives the sidebar `📥 ClaudeCode` badge) stayed null. Single helper `_current_disc_meta()` resolves `{id, project_id, agent}` once per process from `GET /api/discussions//meta`. `disc_create` now defaults TWO fields when the agent omits them: `project_id` (parent project) + `source_agent` (parent agent → makes `SwipeableDiscItem.tsx:147` render the badge). `workflow_create_draft` + `qp_create_draft` only inherit `project_id` (no source-binding columns on those entities). Important non-default: we deliberately **do not** auto-fill `source_session_id` from the parent disc id because `api/disc_source.rs:78` treats `(source_agent, source_session_id)` as an idempotency key — auto-filling both would collapse all sibling MCP-created discs from the same parent to the first one created. Agents pass `source_session_id` explicitly when they actually want one-disc-per-external-session semantics. Caught 2026-05-18 when the user noticed "tu as créé une disc dans Général alors que je suis sur front_euronews" + "je ne peux pas distinguer une conv créée via UI vs MCP dans le sidebar". Both fixed by the same lookup. + +- **MCP autonomous draft creation — `workflow_create_draft` + `qp_create_draft` tools** (`backend/scripts/disc-introspection-mcp.py` + `models/workflows.rs::CreateWorkflowRequest.enabled` + 3 tests) — symmetric path to the existing `KRONN:WORKFLOW_READY` / `KRONN:QP_IMPROVED` signal+button flow. The MCP tools let an agent CREATE the artifact directly when the conversation has converged on a clear design. Safety contract: `workflow_create_draft` ALWAYS forces `enabled: false` server-side regardless of agent payload — drafts can't auto-fire on cron before user review. QPs have no enabled flag (manual launch only). Both tools surface the created id back to the agent so it can tell the user where to find the draft. Use case the user asked for: accelerate Kronn workflow adoption (`Ca [aiderait] aussi à l'adoption des Workflow Kronn`). Tests : `create_workflow_with_enabled_false_persists_as_draft` (the safety contract), `create_workflow_without_enabled_field_defaults_to_true` (back-compat with every UI-driven save), `architect_skills_teach_mcp_draft_creation_tools` (skill guards pin both architect skills explain the new tools). Cf. [[project_mcp_draft_creation_0_8_5]]. + +- **`validate_required_fields_per_type` — safety net behind `#[serde(default)]`** (`backend/src/api/workflows.rs::validate_required_fields_per_type` + `validate_api_call_minimum` helper + 13 tests). The 0.8.5 serde-default change on `WorkflowStep.{agent, prompt_template, mode}` made axum accept previously-rejected minimal payloads, but it ALSO accepted payloads that should still be rejected: `step_type: Agent` with an empty `prompt_template`, `ApiCall` with no `api_endpoint_path`, `BatchQuickPrompt` missing `batch_items_from`, `Notify` with no `notify_config`. Pre-fix those would persist and only blow up at run-time with "step emitted empty response" or "API returned 404 on /". Now the validator runs at every save site (POST `/api/workflows`, PUT, bundle-import wf_from_db) and rejects the payload at the wizard layer with a step-named, field-named error. Rules: Agent needs `prompt_template` OR `quick_prompt_id` ref; ApiCall needs `api_endpoint_path` + (`api_plugin_slug` OR `quick_api_id`); BatchQuickPrompt needs `batch_quick_prompt_id` + `batch_items_from`; BatchApiCall = ApiCall + `batch_items_from`; Notify needs populated `notify_config.url`; Gate / Exec / JsonData deferred to their existing dedicated validators so we don't double-report. Short-circuits on first offender (wizard surfaces one error at a time). Tests cover every variant's missing-field path + QP/QA-ref escape hatches + the deferred-variants no-op + first-offender-wins ordering. Closes the last "release-blocker" risk I'd flagged for 0.8.5. + +- **Python tests for MCP auto-inherit helpers** (`backend/scripts/test_disc_introspection_mcp.py` + `make test-python` + `test-python` job in `.github/workflows/ci-test.yml`). The 0.8.5 `_current_disc_meta` / `_current_project_id` / `call_disc_create` helpers had zero unit-test coverage — only the user's live-by-hand smoke test the day they shipped. Now 10 stdlib-only `unittest` cases pin: cache hit/miss behaviour, `KRONN_DISCUSSION_ID` missing → returns `None` silently, backend unreachable → returns `None` + stderr log (does NOT crash the MCP server), `_current_project_id` derives from the shared cache (no separate HTTP), `call_disc_create` auto-fills `project_id`+`source_agent` from parent, explicit user values override the auto-fill, no parent meta → no inheritance (pre-0.8.5 fallback). The SAFETY-CRITICAL pin: `test_does_not_auto_fill_source_session_id` guards the idempotency-collision fix — if someone reverts this in 6 months thinking they're "improving" the cross-agent memory binding, the test will catch it. Sub-second run on stdlib only (no extra dev deps). CI job is its own lane so it doesn't gate behind the heavy Rust toolchain setup. + +- **Sidebar + ChatHeader expose the discussion id** (`ChatHeader.tsx::disc-id-pill` + `SwipeableDiscItem.tsx::title` attr + `DiscussionSidebar.tsx::matchesFilters` extended with id prefix match + 4 i18n keys × 3 langs). Pre-fix the disc id was never rendered anywhere in the UI — when an agent (e.g. via `kronn-internal` MCP) referenced `04a9c927` in a summary, the user had no way to find that disc back. Now the ChatHeader shows a `#04a9c927` mono pill (click → copy full UUID to clipboard, hover → tooltip with the UUID), the sidebar title tooltip shows the UUID on hover, and the sidebar search input ALSO matches id prefix so pasting `04a9` filters to that disc. Round-trip "agent quotes id → user paste → land on disc" works in 3 keystrokes. + +### Changed + +- **`workflow-architect` skill — canonical envelope + full signal coverage docs** (`backend/src/skills/workflow-architect.md` + new test guard `workflow_architect_skill_teaches_canonical_envelope_and_signal_coverage`). Three sections rewritten: template-variables list now says `.data`/`.summary`/`.status` works for EVERY envelope-producing step type (was "only Structured Agent or ApiCall"); new "Canonical Kronn step-output envelope (0.8.5+)" subsection with byte-for-byte format + per-step-type matrix; Signals table now enumerates `Notify` (OK/ERROR), `JsonData` (OK), `BatchQuickPrompt` (OK/PARTIAL/ERROR/PENDING) as signal-emitting step types (pre-0.8.5 incorrectly said "branching not supported"). Without this update AI-generated workflows would keep emitting the pre-0.8.5 dialects and slowly drift back to two-strategy territory. + +- **Preset `ticket-to-pr.createPrPrompt` × 3 langs** (`frontend/src/lib/i18n.ts`) — bad guidance `Output \`state.pr_url=\`` replaced with the canonical `---STATE:pr_url=---` marker syntax + explicit warning that the marker form is mandatory. Pre-fix the `notify_done` step's `{{state.pr_url}}` reference would resolve to literal because the agent followed the prompt's wrong syntax and Kronn's runner never extracted the state. + +- **`WorkflowStep.{agent, prompt_template, mode}` now `#[serde(default)]`** (`backend/src/models/workflows.rs` + `models/setup.rs`). Pre-fix an ApiCall step's payload was rejected by axum's `Json` extractor with `missing field "prompt_template"` because the fields were required-without-default at the type level — but they're irrelevant for non-LLM step types. Now `AgentType: Default` (variant `ClaudeCode`) and `StepMode: Default` (variant `Normal`) carry the safe defaults. 3 dedicated regression tests (`workflow_step_apicall_deserialises_without_llm_fields`, `workflow_step_agent_roundtrips_with_explicit_fields`, `test_api_call_request_accepts_minimal_step`) pin the contract. + +### Fixed + +- **`Server error (HTTP 422)` swallowed the actual reason** (`frontend/src/lib/api.ts:312-326` + 4 tests). Pre-fix when axum's `Json` extractor rejected a request (returning 422 with `Content-Type: text/plain` and the deserialization failure in the body), the frontend's `api()` helper saw the non-JSON content type and threw a bare `Server error (HTTP 422)` with zero actionable info. Now reads the body via `res.text()`, includes up to 500 chars in the error message (`Server error (HTTP 422) — Failed to deserialize the JSON body: missing field 'agent' at line 1 column 234`). Defensive fallbacks: empty body / `text()` rejection both produce the bare form without throwing. Caught the user during the JIRA helper agent dogfooding when the QP-improver wasted minutes diagnosing a phantom 422 with no body. + +- **QP Improver banner — busy guard + toast + persistent "déployé" state** (`frontend/src/pages/DiscussionsPage.tsx` + `frontend/src/lib/qp-improver-banner.ts` + 9 dedicated tests). Three follow-ups after the 0.8.4 ship: (1) the deploy CTA was a silent `console.warn` on PUT failure → the user saw "click does nothing" when the agent emitted invalid JSON; now `toast(t('qp.deployFailed', userError(e)), 'error')`. (2) `useRef` busy guard against fast double-click (closure-stale `disabled={busy}`, cf. [[feedback_race_guards]]). (3) localStorage-backed "deployed at v\" marker keyed by discussion id — once a QP is deployed, returning to the disc renders a disabled "✅ QP déployé en v3" banner instead of the active CTA. After successful PUT, fetches `quickPromptsApi.history()` to capture the freshly-snapshotted version index, persists, then navigates with toast success. + +- **AgentQuestionForm — false-positive `{{var}}:` in code / inline backticks** (`frontend/src/lib/agent-question-parse.ts` + 6 dedicated tests). Pre-fix the parser matched `{{var}}:` anywhere in the text, so an agent reply containing `--after="{{date}}T{{h1}}:00"` (recommendation prose) or a fenced ` ```json` block with `git log --after=\"{{date}}T{{h1}}:00\"` produced a garbage mini-form with `h1`/`h2` as variable names and `00\" --before=…` as the question body. Fix: (1) new `stripCodeRegions()` blanks fenced ` ```…``` ` and inline ` `…` ` regions in place (preserving newlines so line offsets stay stable). (2) Regex re-anchored to start-of-line with optional bullet (`-/*/+/•`) or ordered-list marker (`1.` / `2)`). Real-form questions stay parsed, code/prose noise is silently ignored. + +- **Wizard launch modal stayed open for the entire run duration** (`frontend/src/pages/WorkflowsPage.tsx`). Pre-fix `await fireTrigger(...)` resolved only when the SSE stream completed — so the launch modal stayed open until the workflow finished (sometimes 30+ min). Now closes immediately after validation; the `liveRun` pane takes over rendering. + +- **CI `pnpm install` ETIMEDOUT on `onnxruntime-node` postinstall** (`frontend/package.json` → `pnpm.neverBuiltDependencies`). The transitive dep tries to download native Microsoft Azure binaries at install time, which times out on GitHub Actions runners. The Whisper STT worker uses `onnxruntime-web` (WASM) in the browser anyway → the Node binaries are never loaded at runtime. `neverBuiltDependencies: ["onnxruntime-node"]` skips the postinstall safely. Lockfile unchanged. + +- **Residual `ai/` references in i18n + 4 source comments → `docs/`** (`frontend/src/lib/i18n.ts` `mcp.contextInfo` × 3 langs + `backend/src/models/mcp.rs:270` + `backend/src/models/workflows.rs:378` + `frontend/src/components/workflows/ApiCallStepCard.tsx:50` + `frontend/src/lib/workflow-templates/chartbeat-top5.ts:5`). Final residues from the 0.7.1 pivot — the `mcp.contextInfo` string was visibly wrong in the MCP drawer (`McpPage.tsx`) showing `ai/operations/mcp-servers/{1}.md` while the backend writes via `detect_docs_dir` → `docs/operations/...` since 0.7.1. + +- **QuickStart picker preset titles showed raw i18n keys** (`frontend/src/lib/workflow-quick-start.ts::fromPreset`) — the adapter set `title: p.id` and `description: p.descKey` so the picker rendered `auto-dev` / `wiz.preset.autoDev.desc` instead of the human strings. Caught by Playwright E2E `wizard-presets.spec.ts` on 2026-05-18. Fix: the builder now takes a `t: Translator` argument and resolves `\`${p.icon} ${t(p.titleKey)}\`` / `t(p.descKey)`. Emoji prefix preserved so `🎫 Ticket Autopilot` stays distinguishable from `🎯 Big-ticket AutoPilot`. Tests fixture updated to pass a `tStub` translator. + +- **desktop-build CI couldn't find DMG/EXE/DEB artifacts** (`.github/workflows/desktop-build.yml`). Since `.cargo/config.toml` set `target-dir = "target"` at the repo root (2026-05-15, cf. [[feedback_rust_target_bloat]]) to mutualise tokio/serde/reqwest between `backend/` and `desktop/src-tauri/`, Tauri builds now land in `/target//release/bundle/...` instead of `/desktop/src-tauri/target//release/bundle/...`. The macOS upload-artifact step failed with `No files were found` because the path glob only listed the legacy location. Fix: every artifact upload (Windows / macOS / Linux) now globs BOTH the legacy `desktop/src-tauri/target/...` AND the shared `target/...` paths. The macOS ad-hoc sign step's bundle-dir lookup also checks all 4 candidates (2 roots × 2 triple prefixes). + +- **Playwright wizard specs broken by the 0.8.5 QuickStart picker refactor** (`frontend/e2e/pages/WorkflowWizardPage.ts` + `e2e/specs/wizard-{presets,save-error,create-button-validation}.spec.ts`). The 0.8.4-era specs queried preset cards on advanced step 2 via `getByRole('button', { name: /🎫\s*Ticket Autopilot/i })`, but 0.8.5 unified all 3 preset sources (STARTER_TEMPLATES, suggestions, v07 presets) into a single picker on step 0 (Infos), rendering rows as `
  • ` with the title in a ``. The page object now exposes `quickStartToggle` + `quickStartRow(re)` + `quickStartApplyButton(re)` + `openQuickStartPicker(name)` / `applyQuickStart(name, titleRe)` helpers. The 3 affected specs were rewritten to use the new flow; backward-compat shims on `presetAutoDev` / `presetTicketToPr` / `presetFeasibilityAutopilot` / `presetDailyHostAudit` return the new row locators so any future spec doesn't need to relearn the picker structure. + +### Test counts + +- Backend : 2123 → **2180** lib (+57 net since 0.8.4). Net new : +6 helper tests, +17 cross-step transmission, +9 manual trigger var injection, +4 endpoint `{{var}}` interpolation, +3 WorkflowStep ApiCall serde, +1 workflow-architect canonical-envelope skill guard, +13 required-fields-per-StepType validator + +4 extras absorbed into other fixes. +- Frontend : 1333 → **1387 vitest** (+54). `qp-improver-banner.test.ts` (+9), `agent-question-parse.test.ts` (+6 cases for code-region exclusion), `workflow-quick-start.test.ts` (+17), `WorkflowQuickStartPicker.test.tsx` (+14), `api.test.ts` (+4 for body-surfacing), `WorkflowQuickStartPicker.test.tsx::disabled gate` (+4). +- Python : 0 → **10** unittest cases on `backend/scripts/disc-introspection-mcp.py` helpers (new `make test-python` + dedicated `test-python` CI job). +- Playwright E2E : unchanged (covered by CI on `ci-test` label). + +### Deferred to 0.8.6 / 0.9.0 + +- `[[project_linked_repos_picker_0_8_5]]` — UX: auto-suggest linked_repos from scan_paths candidates instead of manual path input (a tied-back issue surfaced during the EW-7247 setup). +- `[[project_audit_state_backfill_0_8_5]]` — backfill `docs/.kronn.json` from legacy `checksums.json` / `KRONN:VALIDATED` markers so older audited projects appear as `Validated` without a re-audit. + ## [0.8.4] - 2026-05-17 **Désagentify + push→pull migration + QP polish (AI Improver, version history & metrics, bindings).** diff --git a/Makefile b/Makefile index 2b4dcf97..30840d82 100644 --- a/Makefile +++ b/Makefile @@ -241,6 +241,14 @@ test-backend: @echo "$(CYAN)▸ Running backend tests...$(RESET)" cd backend && cargo test --lib -- --skip export_bindings +## Run Python helper tests (MCP bridge auto-inheritance contract). +## Stdlib `unittest` only — no extra dev deps. The MCP script +## (`disc-introspection-mcp.py`) drives the kronn-internal MCP server +## that 5+ agent CLIs talk to, so its helpers MUST stay green. +test-python: + @echo "$(CYAN)▸ Running Python helper tests...$(RESET)" + python3 -m unittest discover -s backend/scripts -p 'test_*.py' -v + ## Run frontend unit tests (vitest) test-frontend: @echo "$(CYAN)▸ Running frontend unit tests (vitest)...$(RESET)" diff --git a/VERSION b/VERSION index b60d7196..7ada0d30 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.4 +0.8.5 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 4c454eae..991ec116 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1403,7 +1403,7 @@ dependencies = [ [[package]] name = "kronn" -version = "0.8.4" +version = "0.8.5" dependencies = [ "aes-gcm", "anyhow", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f15b2453..c50a9f0c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kronn" -version = "0.8.4" +version = "0.8.5" edition = "2021" description = "Self-hosted AI dev workflow control plane" license = "AGPL-3.0-only" diff --git a/backend/scripts/disc-introspection-mcp.py b/backend/scripts/disc-introspection-mcp.py index 14560835..d120391d 100755 --- a/backend/scripts/disc-introspection-mcp.py +++ b/backend/scripts/disc-introspection-mcp.py @@ -244,6 +244,153 @@ "required": ["disc_id"], }, }, + # ─── 0.8.5 — read-only listings of existing artifacts ─────────────── + # Always call the relevant `*_list` tool BEFORE drafting a new + # artifact: if a fitting one already exists, reference its id + # (`quick_prompt_id`, `quick_api_id`, `api_config_id`) instead of + # duplicating. Compact payload (no full bodies) to keep the agent + # context tight; the `GET /api//` route returns the + # full record when the agent really needs it. + { + "name": "workflow_list", + "description": ( + "List every workflow in the user's Kronn instance — compact " + "view (id, name, enabled, project_id, trigger_type, " + "step_count, step_names, last_run_status, last_run_at). " + "Use this to (a) avoid drafting a duplicate workflow, (b) " + "surface the existing workflow id when the user asks " + "'have I already built something like X?'." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "qp_list", + "description": ( + "List every Quick Prompt in the user's library — compact " + "view (id, name, agent, description, variable_names, " + "skill_ids, project_id, tier). Use this to (a) reuse a " + "matching QP via `quick_prompt_id` / " + "`batch_quick_prompt_id` in a workflow step instead of " + "drafting a duplicate, (b) answer 'do I already have a QP " + "for X?'." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "qa_list", + "description": ( + "List every Quick API in the user's library — compact view " + "(id, name, api_plugin_slug, api_endpoint_path, api_method, " + "description, project_id). Use this to reuse a matching QA " + "via `quick_api_id` in a workflow `ApiCall` / `BatchApiCall` " + "step instead of re-specifying the endpoint inline." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "mcp_list", + "description": ( + "List every MCP / API plugin wired in the user's Kronn " + "instance. Returns `{configs, servers_with_api}` where " + "`configs` lists the user's instances (id + server_id + " + "project scoping) and `servers_with_api` lists every " + "REGISTRY server that exposes a REST API spec (slug + " + "endpoints). Use this to pick the right `api_plugin_slug` " + "+ `api_config_id` when drafting an `ApiCall` step — " + "without it the agent would have to guess plugin slugs." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + # ─── 0.8.5 — autonomous draft creation tools ──────────────────────── + # Symmetric to the `KRONN:WORKFLOW_READY` / `KRONN:QP_IMPROVED` + # signal+button path: these tools let the agent CREATE the artifact + # directly when the conversation has converged on a clear design, + # at the cost of the user's one-click review. Safety: both tools + # force `enabled: false` on the workflow path (no auto-fire on + # cron), and the artifact appears in the user's Workflows / QP + # tab marked as a draft. The signal+button path stays the + # recommended default; the draft tools are for the "agent has + # nailed the design, let's accelerate adoption" scenario. + { + "name": "workflow_create_draft", + "description": ( + "Create a Kronn workflow in DRAFT state (`enabled: false`). " + "The workflow appears in the user's Workflows page and can " + "be reviewed + enabled with one click — no cron fires until " + "the user explicitly enables it. Use this when the design " + "has converged in the conversation and the user signaled " + "they want autonomous creation; otherwise emit a " + "`KRONN:WORKFLOW_READY` block and let the user one-click " + "deploy via the existing UI CTA.\n\n" + "Payload mirrors `CreateWorkflowRequest`: name (required), " + "trigger (required, e.g. `{ \"type\": \"Manual\" }`), steps " + "(required, ≥ 1 ≤ 20 items). Optional: project_id, " + "actions, safety, workspace_config, concurrency_limit, " + "guards, artifacts, on_failure, exec_allowlist, variables.\n\n" + "Returns the created workflow JSON (id, all fields) so the " + "agent can echo the id back to the user (`Workflow drafted " + "as — review and enable in your Workflows page`)." + ), + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Workflow name (1-200 chars)."}, + "trigger": { + "type": "object", + "description": "Workflow trigger spec (Manual / Cron / Tracker). E.g. `{ \"type\": \"Manual\" }` or `{ \"type\": \"Cron\", \"schedule\": \"0 9 * * 1-5\" }`.", + }, + "steps": { + "type": "array", + "description": "Workflow steps (1-20 items). Each step matches the `WorkflowStep` shape — see the `workflow-architect` skill for the canonical schema.", + }, + "project_id": {"type": "string", "description": "Optional Kronn project id to bind the workflow to."}, + "variables": {"type": "array", "description": "Optional manual-launch variables (each `{ name, label?, placeholder?, required?, description? }`)."}, + "guards": {"type": "object", "description": "Optional execution guards (timeout, max_llm_calls, loop_revisits)."}, + "on_failure": {"type": "array", "description": "Optional rollback step chain (Notify / Agent / ApiCall steps)."}, + "exec_allowlist": {"type": "array", "items": {"type": "string"}, "description": "Whitelisted binaries for any Exec steps."}, + "artifacts": {"type": "object", "description": "Optional artifact declarations (name → spec)."}, + "concurrency_limit": {"type": "integer", "description": "Optional max concurrent runs."}, + "safety": {"type": "object", "description": "Optional WorkflowSafety overrides."}, + "actions": {"type": "array", "description": "Optional actions (legacy slot)."}, + "workspace_config": {"type": "object", "description": "Optional workspace mode (Direct / Isolated)."}, + }, + "required": ["name", "trigger", "steps"], + }, + }, + { + "name": "qp_create_draft", + "description": ( + "Create a Kronn Quick Prompt (QP) in the user's QP library. " + "QPs are manual-launch templates; there is no enabled flag " + "(every QP can be launched on demand) so this is roughly " + "the symmetric tool to `workflow_create_draft` but without " + "an auto-fire risk. Use when the conversation converged on " + "a reusable prompt template the user will want to launch " + "again later (e.g. recurring audit prompt, triage prompt). " + "For one-off improvements to an existing QP, prefer the " + "`KRONN:QP_IMPROVED` signal+button flow (`qp-improver` " + "skill) which targets an existing QP by id.\n\n" + "Returns the created QP JSON (id, all fields) so the " + "agent can echo the id back to the user." + ), + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "QP name (1-200 chars, displayed on the QP card)."}, + "prompt_template": {"type": "string", "description": "The template body. Use `{{var}}` for required variables."}, + "agent": {"type": "string", "description": "Default agent: `ClaudeCode` / `Codex` / `Vibe` / `GeminiCli` / `Kiro` / `CopilotCli` / `Ollama` / `Custom`."}, + "variables": {"type": "array", "description": "Variable definitions (each `{ name, label?, placeholder?, required?, description? }`)."}, + "description": {"type": "string", "description": "Optional one-line description (~120 chars max) shown on the QP card."}, + "icon": {"type": "string", "description": "Optional single-emoji prefix shown on the QP card (e.g. `⚡` / `🔍` / `📝`)."}, + "tier": {"type": "string", "description": "Default model tier: `default` / `economy` / `reasoning`."}, + "project_id": {"type": "string", "description": "Optional Kronn project id to bind the QP to."}, + "skill_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional skill bindings."}, + "profile_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional profile bindings."}, + "directive_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional directive bindings."}, + }, + "required": ["name", "prompt_template", "agent"], + }, + }, ] @@ -260,6 +407,60 @@ def _disc_id(): return did +# 0.8.5 — cache the current discussion's meta once per process. Used by +# the mutating tools (disc_create / workflow_create_draft / +# qp_create_draft) to auto-inherit: +# - `project_id` — so agent artifacts land in the active project, +# not "Général" (flagged 2026-05-18 during MCP dogfooding). +# - `source_agent` + `source_session_id` — so the existing 0.8.4 +# sidebar badge ("📥 ClaudeCode") fires on every MCP-created +# disc, making UI-created discs visually distinct from +# agent-created ones at a glance. +# The agent can still override either by passing explicit args. +_CURRENT_DISC_META_CACHE = {"checked": False, "value": None} + + +def _current_disc_meta(): + """Return `{id, project_id, agent}` of the parent disc, or `None`.""" + if _CURRENT_DISC_META_CACHE["checked"]: + return _CURRENT_DISC_META_CACHE["value"] + _CURRENT_DISC_META_CACHE["checked"] = True + try: + disc_id = _disc_id() + except RuntimeError: + # KRONN_DISCUSSION_ID not set (legacy launcher, dev scaffold). + # No inheritance possible; return None silently. + return None + try: + url = f"{_backend_url()}/api/discussions/{disc_id}/meta" + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + data = payload.get("data") or {} + meta = { + "id": disc_id, + "project_id": data.get("project_id"), + "agent": data.get("agent"), + } + _CURRENT_DISC_META_CACHE["value"] = meta + return meta + except Exception as e: + # Lookup failed (backend unreachable, disc not found, etc.). + # Don't fail the caller — the artifact just lands without + # inheritance, same as pre-0.8.5 behaviour. + print( + f"kronn-internal: failed to resolve current disc's meta " + f"({e}); inheritance fields will fall back to defaults", + file=sys.stderr, + ) + return None + + +def _current_project_id(): + meta = _current_disc_meta() + return meta.get("project_id") if meta else None + + def _http(method, path, body=None): url = f"{_backend_url()}{path}" data = json.dumps(body).encode() if body is not None else None @@ -323,6 +524,31 @@ def call_disc_create(args): v = args.get(k) if v is not None: body[k] = v + # 0.8.5 — auto-inherit two fields from the current discussion when + # the agent doesn't pass them explicitly: + # - `project_id`: agent artifacts land in the active project, not + # "Général" (flagged 2026-05-18). + # - `source_agent`: makes the existing 0.8.4 sidebar badge + # ("📥 ClaudeCode") fire on every MCP-created disc so the user + # can visually distinguish UI-created vs agent-created discs at + # a glance. The badge only checks for `sourceAgent` truthy + # (cf. `SwipeableDiscItem.tsx:147`), so we don't need + # `source_session_id` to render it. + # We intentionally DO NOT auto-fill `source_session_id`: the + # `/api/disc/create` endpoint treats `(source_agent, + # source_session_id)` as an idempotency key (cf. + # `api/disc_source.rs:78`). If we always set session = parent + # disc id, the second MCP call from the same parent would + # collapse to the first child disc instead of creating a new + # one. Agents can still pass `source_session_id` explicitly when + # they actually want one-disc-per-external-session semantics. + # Cf. [[project_mcp_draft_creation_0_8_5]]. + meta = _current_disc_meta() + if meta: + if "project_id" not in body and meta.get("project_id"): + body["project_id"] = meta["project_id"] + if "source_agent" not in body and meta.get("agent"): + body["source_agent"] = meta["agent"] return _unwrap(_http("POST", "/api/disc/create", body)) @@ -393,6 +619,157 @@ def call_disc_load_other(args): return _unwrap(_http("GET", f"/api/disc/load_other?{qs}")) +def call_workflow_list(_args): + # 0.8.5 — compact list of existing workflows. `GET /api/workflows` + # already returns the summary shape (`WorkflowSummary` — no + # `steps` body, only flat `trigger_type` + `step_count`), so we + # pass it through verbatim minus a couple unused fields. The full + # body is one `GET /api/workflows/` call away when the agent + # needs the step details — e.g. to read the prompt of an existing + # step before drafting a similar one. + data = _unwrap(_http("GET", "/api/workflows")) or [] + out = [] + for w in data: + out.append({ + "id": w.get("id"), + "name": w.get("name"), + "enabled": w.get("enabled"), + "project_id": w.get("project_id"), + "project_name": w.get("project_name"), + "trigger_type": w.get("trigger_type"), + "step_count": w.get("step_count"), + "last_run_status": (w.get("last_run") or {}).get("status"), + "last_run_started_at": (w.get("last_run") or {}).get("started_at"), + }) + return out + + +def call_qp_list(_args): + # 0.8.5 — compact list. Keeps variable names so the agent can decide + # if an existing QP fits the user's use case before drafting a new + # one. Drops the full `prompt_template` body (the agent can call + # `GET /api/quick-prompts/` if it really needs the body). + data = _unwrap(_http("GET", "/api/quick-prompts")) or [] + out = [] + for q in data: + var_names = [v.get("name") for v in (q.get("variables") or [])] + out.append({ + "id": q.get("id"), + "name": q.get("name"), + "agent": q.get("agent"), + "description": q.get("description"), + "variable_names": var_names, + "skill_ids": q.get("skill_ids") or [], + "project_id": q.get("project_id"), + "tier": q.get("tier"), + }) + return out + + +def call_qa_list(_args): + # 0.8.5 — compact list. Keeps the plugin slug + endpoint path so the + # agent can decide if an existing QA can be referenced from a new + # workflow's `quick_api_id` slot. + data = _unwrap(_http("GET", "/api/quick-apis")) or [] + out = [] + for q in data: + out.append({ + "id": q.get("id"), + "name": q.get("name"), + "api_plugin_slug": q.get("api_plugin_slug"), + "api_endpoint_path": q.get("api_endpoint_path"), + "api_method": q.get("api_method"), + "description": q.get("description"), + "project_id": q.get("project_id"), + }) + return out + + +def call_mcp_list(_args): + # 0.8.5 — wired MCP configs (the API plugin slug + config id the + # workflow ApiCall steps need). Drops env values (secrets) and + # scan diagnostics; keeps only what the agent needs to compose an + # ApiCall step (slug + config_id + project scoping). + data = _unwrap(_http("GET", "/api/mcps")) or {} + out_configs = [] + for c in data.get("configs") or []: + out_configs.append({ + "config_id": c.get("id"), + "server_id": c.get("server_id"), + "is_global": c.get("is_global"), + "project_ids": c.get("project_ids") or [], + "label": c.get("label"), + }) + # Server registry (which slugs are KNOWN and have an api_spec) — + # lets the agent answer "what API plugins are available to wire?". + out_servers = [] + for s in data.get("servers") or []: + if s.get("api_spec"): + out_servers.append({ + "id": s.get("id"), + "name": s.get("name"), + "tags": s.get("tags") or [], + "endpoints": [ + {"path": e.get("path"), "method": e.get("method")} + for e in ((s.get("api_spec") or {}).get("endpoints") or []) + ], + }) + return {"configs": out_configs, "servers_with_api": out_servers} + + +def call_workflow_create_draft(args): + # 0.8.5 — POST /api/workflows with `enabled: false` (forced + # client-side; the backend honours the flag since 0.8.5). The + # agent provides everything else; we validate name + trigger + + # steps presence to surface a clean error before the round-trip + # if the LLM forgot a required field. + for field in ("name", "trigger", "steps"): + if not args.get(field): + raise RuntimeError(f"workflow_create_draft: missing required '{field}'") + if not isinstance(args["steps"], list) or len(args["steps"]) == 0: + raise RuntimeError("workflow_create_draft: 'steps' must be a non-empty list") + if len(args["steps"]) > 20: + raise RuntimeError( + f"workflow_create_draft: too many steps ({len(args['steps'])}, max 20)" + ) + # Always force enabled=false on the draft path. Even if the agent + # tries to override, the safety property of the tool stays + # ("drafts never auto-fire"). + body = dict(args) + body["enabled"] = False + # 0.8.5 — auto-inherit project binding from the current discussion + # when the agent doesn't pass one explicitly. Same UX rationale as + # `disc_create` — an agent operating in a project's disc shouldn't + # silently leak its artifacts into "Général". + if "project_id" not in body or body.get("project_id") is None: + inherited = _current_project_id() + if inherited: + body["project_id"] = inherited + return _unwrap(_http("POST", "/api/workflows", body)) + + +def call_qp_create_draft(args): + # 0.8.5 — POST /api/quick-prompts. QPs have no enabled flag (manual + # launch only), so "draft" is semantic — the agent created it, + # the user reviews + launches when they want. + for field in ("name", "prompt_template", "agent"): + if not args.get(field): + raise RuntimeError(f"qp_create_draft: missing required '{field}'") + # Defensive: cap obviously-bad name lengths early. + if len(args["name"]) > 200: + raise RuntimeError( + f"qp_create_draft: 'name' too long ({len(args['name'])} chars, max 200)" + ) + body = dict(args) + # 0.8.5 — auto-inherit project binding from the current discussion + # when the agent doesn't pass one explicitly. + if "project_id" not in body or body.get("project_id") is None: + inherited = _current_project_id() + if inherited: + body["project_id"] = inherited + return _unwrap(_http("POST", "/api/quick-prompts", body)) + + DISPATCH = { "disc_meta": call_disc_meta, "disc_get_message": call_disc_get_message, @@ -405,6 +782,18 @@ def call_disc_load_other(args): "disc_find_by_session": call_disc_find_by_session, "disc_search": call_disc_search, "disc_load_other": call_disc_load_other, + # 0.8.5 — read-only listings of existing artifacts. Lets the + # agent avoid duplicates + reference existing QP/QA ids from a + # new workflow without asking the user to paste them. + "workflow_list": call_workflow_list, + "qp_list": call_qp_list, + "qa_list": call_qa_list, + "mcp_list": call_mcp_list, + # 0.8.5 — autonomous draft creation. Both default to a safe state + # (workflow disabled / QP manually launched) so a misfire can't + # cascade into prod cron. + "workflow_create_draft": call_workflow_create_draft, + "qp_create_draft": call_qp_create_draft, } diff --git a/backend/scripts/test_disc_introspection_mcp.py b/backend/scripts/test_disc_introspection_mcp.py new file mode 100644 index 00000000..6b278d60 --- /dev/null +++ b/backend/scripts/test_disc_introspection_mcp.py @@ -0,0 +1,243 @@ +"""Unit tests for the kronn-internal MCP bridge helpers. + +Run from the repo root: + python3 -m unittest backend.scripts.test_disc_introspection_mcp +or via the Makefile: + make test-python + +These tests exercise the three pieces of the 0.8.5 auto-inheritance +plumbing whose only validation before this file was live-by-eyeball: + + * `_current_disc_meta()` — cache behaviour, env-var handling, error + paths (backend unreachable / missing KRONN_DISCUSSION_ID). + * `_current_project_id()` — derives from the meta cache, no separate + HTTP call. + * `call_disc_create()` — auto-fills `project_id` + `source_agent` + from the parent disc, does NOT touch `source_session_id` (the + idempotency-collision guard documented at + `disc-introspection-mcp.py:527`). + +We mock at two boundaries: + - `urllib.request.urlopen` for `_current_disc_meta()`'s direct HTTP. + - the module-level `_http` for `call_disc_create()` — easier than + threading a urlopen mock through both the meta lookup and the + create POST, and `_http` already has its own integration coverage + via the live-run smoke test the user runs by hand. + +Stdlib `unittest` + `unittest.mock` only — zero extra dev deps, +matches the script's own "no third-party packages" discipline. +""" + +import io +import importlib.util +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +_SCRIPT = Path(__file__).resolve().parent / "disc-introspection-mcp.py" + + +def _load_module(): + """Load disc-introspection-mcp.py despite the kebab-case filename. + + Standard `import` can't handle the hyphens, so we use importlib's + file-loader API. Re-loaded fresh in `setUp` so per-process caches + (`_CURRENT_DISC_META_CACHE`) don't leak between tests. + """ + spec = importlib.util.spec_from_file_location("kronn_mcp", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CurrentDiscMetaCacheTests(unittest.TestCase): + """Behaviour of `_current_disc_meta()` and its cache.""" + + def setUp(self): + self.mod = _load_module() + # Each test starts with the env we control and a fresh cache. + self.env_patch = mock.patch.dict( + os.environ, + { + "KRONN_DISCUSSION_ID": "disc-abc", + "KRONN_BACKEND_URL": "http://127.0.0.1:3140", + }, + clear=False, + ) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def _fake_response(self, payload): + """Build a context-manager that mimics `urlopen()`'s usage.""" + body = (b'{"success": true, "data": ' + payload.encode() + b'}') + cm = mock.MagicMock() + cm.__enter__.return_value.read.return_value = body + cm.__exit__.return_value = False + return cm + + def test_cache_miss_fetches_from_backend_and_returns_struct(self): + """First call to `_current_disc_meta` hits HTTP and returns the + triple `{id, project_id, agent}`.""" + payload = ( + '{"project_id": "proj-front-eu", "agent": "ClaudeCode", ' + '"message_count": 12}' + ) + with mock.patch("urllib.request.urlopen", return_value=self._fake_response(payload)) as urlopen: + meta = self.mod._current_disc_meta() + self.assertIsNotNone(meta) + self.assertEqual(meta["id"], "disc-abc") + self.assertEqual(meta["project_id"], "proj-front-eu") + self.assertEqual(meta["agent"], "ClaudeCode") + urlopen.assert_called_once() + + def test_cache_hit_does_not_refetch(self): + """Second call returns the cached value with zero HTTP traffic + — guarantees the auto-inherit helpers don't multiply backend + load per MCP tool invocation.""" + payload = '{"project_id": "proj-a", "agent": "ClaudeCode"}' + with mock.patch("urllib.request.urlopen", return_value=self._fake_response(payload)) as urlopen: + self.mod._current_disc_meta() + self.mod._current_disc_meta() + self.mod._current_disc_meta() + self.assertEqual(urlopen.call_count, 1, "cache must short-circuit follow-up calls") + + def test_missing_kronn_discussion_id_returns_none_silently(self): + """If the launcher didn't set `KRONN_DISCUSSION_ID`, the helper + must NOT raise — it has to return `None` so callers fall back + to pre-0.8.5 behaviour (no inheritance). The cache still gets + marked `checked` to avoid repeat env probes.""" + with mock.patch.dict(os.environ, {}, clear=True), \ + mock.patch("urllib.request.urlopen") as urlopen: + meta = self.mod._current_disc_meta() + self.assertIsNone(meta) + urlopen.assert_not_called() + # Calling again should also be a no-op (cache marked checked). + with mock.patch("urllib.request.urlopen") as urlopen2: + self.mod._current_disc_meta() + urlopen2.assert_not_called() + + def test_backend_unreachable_returns_none_and_logs_to_stderr(self): + """If the backend is down (urlopen raises), the helper must + swallow the error, return `None`, and surface a stderr line so + the user can investigate. It must NOT crash the MCP server — + every tool call would then 500 and the agent loop dies.""" + import urllib.error + err = urllib.error.URLError("Connection refused") + with mock.patch("urllib.request.urlopen", side_effect=err), \ + mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + meta = self.mod._current_disc_meta() + self.assertIsNone(meta) + self.assertIn("failed to resolve current disc's meta", stderr.getvalue()) + + def test_current_project_id_derives_from_meta(self): + """`_current_project_id` is a thin accessor over the same + cache — no separate HTTP.""" + payload = '{"project_id": "proj-xyz", "agent": "Codex"}' + with mock.patch("urllib.request.urlopen", return_value=self._fake_response(payload)) as urlopen: + pid = self.mod._current_project_id() + # Second call must hit the cache, not the network. + self.mod._current_project_id() + self.assertEqual(pid, "proj-xyz") + self.assertEqual(urlopen.call_count, 1) + + +class CallDiscCreateAutoInheritTests(unittest.TestCase): + """The auto-fill contract on `call_disc_create`. + + We mock `_http` directly so we can inspect the body the helper + sends to `/api/disc/create`. The cache is pre-seeded so each test + runs against a known `{id, project_id, agent}` parent. + """ + + def setUp(self): + self.mod = _load_module() + self.mod._CURRENT_DISC_META_CACHE.update({ + "checked": True, + "value": { + "id": "disc-parent", + "project_id": "proj-front-eu", + "agent": "ClaudeCode", + }, + }) + # _http normally returns the parsed envelope. We mimic the + # success-shape so `_unwrap()` extracts our payload. + self.fake_http = mock.MagicMock(return_value={ + "success": True, + "data": {"disc_id": "disc-new", "created": True}, + }) + self.http_patch = mock.patch.object(self.mod, "_http", self.fake_http) + self.http_patch.start() + self.addCleanup(self.http_patch.stop) + + def test_auto_fills_project_id_and_source_agent_when_omitted(self): + """The killer path — agent calls `disc_create` with only the + 2 required args. Validator must inject the inherited project + binding + source_agent so the disc lands in the right project + AND renders the sidebar 📥 ClaudeCode badge.""" + self.mod.call_disc_create({"title": "New chat", "agent": "Codex"}) + method, path, body = self.fake_http.call_args.args + self.assertEqual(method, "POST") + self.assertEqual(path, "/api/disc/create") + self.assertEqual(body["project_id"], "proj-front-eu") + self.assertEqual(body["source_agent"], "ClaudeCode") + # The user-supplied agent (the child's CLI) is NOT the source_agent + # — source_agent identifies the AGENT THAT TRIGGERED THE CREATE, + # which is the parent disc's runtime. + self.assertEqual(body["agent"], "Codex") + + def test_does_not_auto_fill_source_session_id(self): + """SAFETY-CRITICAL: `(source_agent, source_session_id)` is the + idempotency key in `api/disc_source.rs::disc_create`. Auto- + filling both with `(parent.agent, parent.id)` would collapse + every sibling MCP-created disc back to the first one. + + This test pins that we leave session-id to the agent. If you + change this, also update the idempotency contract docs.""" + self.mod.call_disc_create({"title": "Child", "agent": "Codex"}) + _, _, body = self.fake_http.call_args.args + self.assertNotIn("source_session_id", body) + + def test_explicit_values_are_not_overridden(self): + """An agent passing `project_id` / `source_agent` explicitly + must win — auto-fill is the fallback, not a steamroller.""" + self.mod.call_disc_create({ + "title": "Cross-project share", + "agent": "Codex", + "project_id": "proj-different", + "source_agent": "ManualImport", + "source_session_id": "ext-sess-42", + }) + _, _, body = self.fake_http.call_args.args + self.assertEqual(body["project_id"], "proj-different") + self.assertEqual(body["source_agent"], "ManualImport") + self.assertEqual(body["source_session_id"], "ext-sess-42") + + def test_missing_required_fields_raise_runtime_error_with_clear_message(self): + """Pre-0.8.5 the script's own assertions had to stay sharp — + the wizard isn't on the path here, so a malformed agent payload + would 500 the MCP if we didn't guard. The error messages must + name the missing field so the agent can self-correct.""" + with self.assertRaises(RuntimeError) as ctx: + self.mod.call_disc_create({"agent": "Codex"}) + self.assertIn("title", str(ctx.exception)) + + with self.assertRaises(RuntimeError) as ctx: + self.mod.call_disc_create({"title": "x"}) + self.assertIn("agent", str(ctx.exception)) + + def test_no_parent_meta_skips_inheritance_silently(self): + """Legacy / dev launchers might not set KRONN_DISCUSSION_ID. + The helper must still create the disc, just without + inherited fields — same as pre-0.8.5 behaviour.""" + self.mod._CURRENT_DISC_META_CACHE.update({"checked": True, "value": None}) + self.mod.call_disc_create({"title": "Standalone", "agent": "Codex"}) + _, _, body = self.fake_http.call_args.args + self.assertNotIn("project_id", body) + self.assertNotIn("source_agent", body) + self.assertNotIn("source_session_id", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/src/api/workflows.rs b/backend/src/api/workflows.rs index 9f594a1b..50048147 100644 --- a/backend/src/api/workflows.rs +++ b/backend/src/api/workflows.rs @@ -19,7 +19,68 @@ type SseStream = Pin> + Send>>; /// minimal validation: empty path, absolute (Unix `/` or Windows drive), /// or any segment equal to `..`. Path canonicalisation at write-time is /// deferred to the runner (which knows the workspace root); this is only -/// the save-time hard reject. +/// 0.8.5 — pure builder for a workflow run's `trigger_context` object on +/// manual launch. Extracted from the SSE handler so the variable +/// injection contract can be pinned in unit tests — pre-extraction +/// there was ZERO coverage and a regression silently dropped every +/// auto-detected `{{var}}` from launch modals (caught during EW-7247 +/// AutoPilot dogfooding on 2026-05-17). +/// +/// Contract: +/// - Always seeds `type: "manual"` + `triggered_at: `. +/// - Accepts EVERY caller-provided variable, not just those declared +/// in `wf.variables`. The frontend modal asks for declared + +/// auto-detected `{{var}}` refs from step templates, so a legit +/// value can arrive without being in the declared list — silently +/// dropping it was the bug. +/// - Filters variable names with a conservative safety check: +/// non-empty, ≤ 64 chars, ASCII word chars + dot only. Anything +/// else is logged + skipped to keep `{{path/../etc}}` style keys +/// out of the template context. +pub(crate) fn build_manual_trigger_obj( + provided_vars: &::std::collections::HashMap, + triggered_at: chrono::DateTime, +) -> serde_json::Map { + /// Names that the trigger handler owns — a user-supplied var with + /// one of these names is dropped (with a warning) so a launch + /// payload can't spoof the run's metadata or impersonate the + /// trigger source. + const RESERVED_KEYS: &[&str] = &["type", "triggered_at"]; + + let mut obj = serde_json::Map::new(); + // User vars first so reserved seeds always overwrite below. + for (name, val) in provided_vars { + if !is_safe_trigger_var_name(name) { + tracing::warn!("Workflow trigger: dropping malformed variable name `{}`", name); + continue; + } + if RESERVED_KEYS.contains(&name.as_str()) { + tracing::warn!("Workflow trigger: dropping reserved variable name `{}`", name); + continue; + } + obj.insert(name.clone(), serde_json::Value::String(val.clone())); + } + // Seed reserved keys AFTER user vars so they are authoritative + // (defence in depth on top of the explicit RESERVED_KEYS check). + obj.insert("type".into(), serde_json::Value::String("manual".into())); + obj.insert( + "triggered_at".into(), + serde_json::Value::String(triggered_at.to_rfc3339()), + ); + obj +} + +/// Conservative identifier shape used by [`build_manual_trigger_obj`]. +/// Matches the convention the runner's `inject_trigger_context` expects +/// for top-level template keys (`{{name}}` / `{{ns.field}}`). +fn is_safe_trigger_var_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + fn validate_artifact_specs( specs: &::std::collections::HashMap, ) -> Result<(), String> { @@ -175,6 +236,122 @@ fn validate_json_data_steps(steps: &[WorkflowStep]) -> Result<(), String> { Ok(()) } +/// 0.8.5 — enforce the per-`StepType` required-fields contract that +/// `#[serde(default)]` on `WorkflowStep.{agent,prompt_template,mode}` +/// stopped enforcing at the JSON layer. +/// +/// Background. Before 0.8.5, axum's `Json` extractor +/// rejected any ApiCall/Exec/Gate/Notify/JsonData payload that omitted +/// `prompt_template` or `agent` (irrelevant for non-LLM steps) with a +/// confusing "missing field" 422. We made those fields default-able so +/// the wizard could send minimal payloads — but that means the API +/// will now happily accept a `step_type: Agent` with an empty +/// `prompt_template`, deferring the failure to run-time where the user +/// sees "step Agent emitted empty response" instead of the actual +/// cause. This validator closes that gap at save time so the wizard +/// can surface the real error inline. +/// +/// Rules, per `StepType`: +/// - `Agent` — needs `prompt_template` non-empty, UNLESS +/// `quick_prompt_id` is set (then the prompt body comes from the +/// referenced QP at run-time). +/// - `ApiCall` — needs `api_endpoint_path` non-empty AND +/// `api_plugin_slug` non-empty (or `quick_api_id` referencing a +/// saved Quick API — same per-field override pattern as `Agent`). +/// - `BatchQuickPrompt` — needs `batch_quick_prompt_id` AND +/// `batch_items_from` (the array source to fan out over). +/// - `BatchApiCall` — same as `ApiCall` PLUS `batch_items_from`. +/// - `Notify` — needs `notify_config` populated (URL is the minimal +/// contract — body / method have safe defaults). +/// - `Gate`, `Exec`, `JsonData` — covered by their existing +/// dedicated validators; this function is a no-op for them. +fn validate_required_fields_per_type(steps: &[WorkflowStep]) -> Result<(), String> { + for s in steps { + match s.step_type { + StepType::Agent => { + let has_inline = !s.prompt_template.trim().is_empty(); + let has_qp_ref = s.quick_prompt_id.as_deref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + if !has_inline && !has_qp_ref { + return Err(format!( + "Step Agent « {} » : `prompt_template` est obligatoire (ou bien lie un Quick Prompt via `quick_prompt_id`).", + s.name + )); + } + } + StepType::ApiCall => validate_api_call_minimum(s, false)?, + StepType::BatchApiCall => validate_api_call_minimum(s, true)?, + StepType::BatchQuickPrompt => { + if s.batch_quick_prompt_id.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(format!( + "Step BatchQuickPrompt « {} » : `batch_quick_prompt_id` est obligatoire (le QP à fan-out).", + s.name + )); + } + if s.batch_items_from.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(format!( + "Step BatchQuickPrompt « {} » : `batch_items_from` est obligatoire (ex. `{{{{steps.fetch.data.items}}}}`).", + s.name + )); + } + } + StepType::Notify => { + let cfg = match s.notify_config.as_ref() { + Some(c) => c, + None => return Err(format!( + "Step Notify « {} » : `notify_config` est obligatoire (URL + body).", + s.name + )), + }; + if cfg.url.trim().is_empty() { + return Err(format!( + "Step Notify « {} » : `notify_config.url` ne peut pas être vide.", + s.name + )); + } + } + // Other variants have their own dedicated validators: + // Exec → validate_exec_steps + // JsonData → validate_json_data_steps + // Gate → no required fields beyond its serde defaults + StepType::Exec | StepType::JsonData | StepType::Gate => {} + } + } + Ok(()) +} + +/// Helper for `ApiCall` + `BatchApiCall`. `is_batch` adds the +/// `batch_items_from` requirement on top of the shared API minimum. +fn validate_api_call_minimum(s: &WorkflowStep, is_batch: bool) -> Result<(), String> { + let kind = if is_batch { "BatchApiCall" } else { "ApiCall" }; + if s.api_endpoint_path.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(format!( + "Step {} « {} » : `api_endpoint_path` est obligatoire (ex. `/rest/api/3/issue/{{{{issue_key}}}}`).", + kind, s.name + )); + } + let has_plugin = s.api_plugin_slug.as_deref().map(str::trim) + .map(|v| !v.is_empty()) + .unwrap_or(false); + let has_qa_ref = s.quick_api_id.as_deref().map(str::trim) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_plugin && !has_qa_ref { + return Err(format!( + "Step {} « {} » : il faut soit `api_plugin_slug` (registry MCP) soit `quick_api_id` (Quick API saved).", + kind, s.name + )); + } + if is_batch && s.batch_items_from.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(format!( + "Step BatchApiCall « {} » : `batch_items_from` est obligatoire (la source d'items à itérer).", + s.name + )); + } + Ok(()) +} + /// 0.7.0 Phase 5 — validate every `StepType::Exec` step in the list: /// - `exec_command` is set, non-empty, and present in `allowlist` /// - `exec_command` itself passes the same character-level safety @@ -405,6 +582,13 @@ pub async fn create( return Json(ApiResponse::err(e)); } + if let Err(e) = validate_required_fields_per_type(&req.steps) { + return Json(ApiResponse::err(e)); + } + if let Err(e) = validate_required_fields_per_type(&req.on_failure) { + return Json(ApiResponse::err(e)); + } + let now = Utc::now(); let wf = Workflow { id: Uuid::new_v4().to_string(), @@ -426,7 +610,11 @@ pub async fn create( on_failure: req.on_failure, exec_allowlist: req.exec_allowlist, variables: req.variables, - enabled: true, + // 0.8.5 — accept an `enabled: false` from the request for the + // MCP draft path (`workflow_create_draft`). Default stays true + // to preserve back-compat with every UI-driven save. Cf. + // [[project_mcp_draft_creation_0_8_5]]. + enabled: req.enabled.unwrap_or(true), created_at: now, updated_at: now, }; @@ -557,6 +745,9 @@ pub async fn update( if let Err(e) = validate_json_data_steps(new_steps) { return Json(ApiResponse::err(e)); } + if let Err(e) = validate_required_fields_per_type(new_steps) { + return Json(ApiResponse::err(e)); + } } if let Some(ref new_on_failure) = req.on_failure { if let Err(e) = validate_exec_steps(new_on_failure, effective_allowlist) { @@ -565,6 +756,9 @@ pub async fn update( if let Err(e) = validate_json_data_steps(new_on_failure) { return Json(ApiResponse::err(e)); } + if let Err(e) = validate_required_fields_per_type(new_on_failure) { + return Json(ApiResponse::err(e)); + } } let updated = Workflow { @@ -759,6 +953,12 @@ pub async fn import_workflow( if let Err(e) = validate_exec_steps(&wf.on_failure, &wf.exec_allowlist) { return Json(ApiResponse::err(e)); } + if let Err(e) = validate_required_fields_per_type(&wf.steps) { + return Json(ApiResponse::err(e)); + } + if let Err(e) = validate_required_fields_per_type(&wf.on_failure) { + return Json(ApiResponse::err(e)); + } // Build a remap table for QP ids (source → fresh) and insert the // bundled QPs first. If a step references a QP that's NOT bundled, @@ -869,14 +1069,7 @@ pub async fn trigger( } } } - let mut trigger_obj = serde_json::Map::new(); - trigger_obj.insert("type".into(), serde_json::Value::String("manual".into())); - trigger_obj.insert("triggered_at".into(), serde_json::Value::String(Utc::now().to_rfc3339())); - for declared in &wf.variables { - if let Some(val) = provided_vars.get(&declared.name) { - trigger_obj.insert(declared.name.clone(), serde_json::Value::String(val.clone())); - } - } + let trigger_obj = build_manual_trigger_obj(&provided_vars, Utc::now()); // Atomic concurrency check + insert in a single transaction (avoids TOCTOU race) let now = Utc::now(); @@ -3091,4 +3284,270 @@ mod tests { assert_eq!(value_type_tag(&serde_json::json!([1, 2])), "array(2)"); assert_eq!(value_type_tag(&serde_json::json!({ "k": 1 })), "object"); } + + // ─── 0.8.5 — manual-trigger variable injection ───────────────────── + // Critical regression coverage. Pre-fix the SSE trigger handler only + // forwarded variables that appeared in `wf.variables` (the declared + // list), silently dropping any auto-detected `{{var}}` the launch + // modal had asked the user to fill. Result: workflows fired with + // literal `{{var}}` strings in their step prompts → 404s, broken + // templates, no clue why. Caught during EW-7247 AutoPilot dogfooding. + + use std::collections::HashMap; + + fn provided(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn build_manual_trigger_obj_seeds_type_and_timestamp() { + let obj = build_manual_trigger_obj(&HashMap::new(), Utc::now()); + assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("manual")); + assert!(obj.get("triggered_at").and_then(|v| v.as_str()).is_some()); + } + + #[test] + fn build_manual_trigger_obj_passes_auto_detected_var_through() { + // The bug case: a workflow with `variables: null` whose step + // template references `{{issue_key}}`. The frontend asks the + // user for `issue_key` (auto-detected) and POSTs it. Pre-fix + // the value was dropped. Now it must land in the trigger_obj + // verbatim so `inject_trigger_context` can expose it. + let obj = build_manual_trigger_obj(&provided(&[("issue_key", "EW-7247")]), Utc::now()); + assert_eq!(obj.get("issue_key").and_then(|v| v.as_str()), Some("EW-7247")); + } + + #[test] + fn build_manual_trigger_obj_passes_multiple_vars() { + let obj = build_manual_trigger_obj( + &provided(&[("ticket", "EW-1"), ("env", "staging"), ("dry_run", "true")]), + Utc::now(), + ); + assert_eq!(obj.get("ticket").and_then(|v| v.as_str()), Some("EW-1")); + assert_eq!(obj.get("env").and_then(|v| v.as_str()), Some("staging")); + assert_eq!(obj.get("dry_run").and_then(|v| v.as_str()), Some("true")); + } + + #[test] + fn build_manual_trigger_obj_accepts_dotted_namespaced_names() { + // Dotted keys are legitimate — `inject_trigger_context` uses + // `{{issue.title}}` etc. for tracker payloads. + let obj = build_manual_trigger_obj(&provided(&[("issue.title", "Hello")]), Utc::now()); + assert_eq!(obj.get("issue.title").and_then(|v| v.as_str()), Some("Hello")); + } + + #[test] + fn build_manual_trigger_obj_drops_var_with_special_characters() { + // Path-traversal-ish keys: must NOT land in the template ctx. + let obj = build_manual_trigger_obj( + &provided(&[ + ("../etc/passwd", "leak"), + ("foo bar", "spaces"), + ("a-b", "hyphen"), + ("a$b", "dollar"), + ]), + Utc::now(), + ); + assert!(obj.get("../etc/passwd").is_none()); + assert!(obj.get("foo bar").is_none()); + assert!(obj.get("a-b").is_none()); + assert!(obj.get("a$b").is_none()); + } + + #[test] + fn build_manual_trigger_obj_drops_empty_var_name() { + let obj = build_manual_trigger_obj(&provided(&[("", "v")]), Utc::now()); + assert!(obj.get("").is_none()); + } + + #[test] + fn build_manual_trigger_obj_drops_var_name_over_64_chars() { + let long = "a".repeat(65); + let obj = build_manual_trigger_obj(&provided(&[(long.as_str(), "v")]), Utc::now()); + assert!(obj.get(&long).is_none()); + // A 64-char name passes (boundary). + let ok = "a".repeat(64); + let obj2 = build_manual_trigger_obj(&provided(&[(ok.as_str(), "v")]), Utc::now()); + assert_eq!(obj2.get(&ok).and_then(|v| v.as_str()), Some("v")); + } + + #[test] + fn build_manual_trigger_obj_preserves_empty_value() { + // Required-var validation runs upstream; here we accept the + // empty string so workflows can decide to fall back to a + // default in their template (`{{flag|default("off")}}` etc.). + let obj = build_manual_trigger_obj(&provided(&[("flag", "")]), Utc::now()); + assert_eq!(obj.get("flag").and_then(|v| v.as_str()), Some("")); + } + + #[test] + fn build_manual_trigger_obj_reserved_keys_cannot_be_spoofed_by_user() { + // A user-supplied `type` or `triggered_at` MUST NOT overwrite + // the trigger handler's authoritative values. Without this + // pin, an attacker who controls the launch payload (or a + // careless workflow) could impersonate a cron trigger or + // backdate the run. + let now = Utc::now(); + let obj = build_manual_trigger_obj( + &provided(&[ + ("type", "Cron"), + ("triggered_at", "1970-01-01T00:00:00Z"), + ]), + now, + ); + assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("manual")); + let ts = obj.get("triggered_at").and_then(|v| v.as_str()).unwrap(); + assert!(ts.starts_with(&now.format("%Y-%m-%d").to_string())); + assert_ne!(ts, "1970-01-01T00:00:00Z"); + } + + // ─── 0.8.5 — required-fields-per-StepType ──────────────────────────── + // + // Gap closed: serde defaults on `WorkflowStep.{agent, prompt_template, + // mode}` (introduced 0.8.5 to unblock minimal ApiCall payloads) made + // the JSON layer permissive. Without this validator, `step_type: + // Agent` with an empty `prompt_template` would persist and only blow + // up at run-time with an unhelpful "step emitted empty response". + + #[test] + fn required_fields_agent_rejects_empty_prompt_template() { + let mut s = mk_step("plan", StepType::Agent); + s.prompt_template = String::new(); + let err = validate_required_fields_per_type(&[s]).expect_err("must reject"); + assert!(err.contains("plan"), "should name the offending step, got: {}", err); + assert!(err.contains("prompt_template"), "should name the field, got: {}", err); + } + + #[test] + fn required_fields_agent_accepts_quick_prompt_id_in_lieu_of_inline_template() { + // Pattern: a step that just references a saved QP — the prompt + // body comes from the QP at run-time, so inline `prompt_template` + // is allowed to be empty. + let mut s = mk_step("plan", StepType::Agent); + s.prompt_template = String::new(); + s.quick_prompt_id = Some("qp-architect".into()); + validate_required_fields_per_type(&[s]).expect("QP-ref Agent step should validate"); + } + + #[test] + fn required_fields_agent_with_whitespace_only_template_is_rejected() { + let mut s = mk_step("plan", StepType::Agent); + s.prompt_template = " \n\t ".into(); + let err = validate_required_fields_per_type(&[s]).expect_err("whitespace is empty"); + assert!(err.contains("plan")); + } + + #[test] + fn required_fields_apicall_rejects_missing_endpoint_path() { + let mut s = mk_step("fetch_issue", StepType::ApiCall); + s.api_plugin_slug = Some("jira".into()); + s.api_endpoint_path = None; + let err = validate_required_fields_per_type(&[s]).expect_err("must reject"); + assert!(err.contains("fetch_issue")); + assert!(err.contains("api_endpoint_path")); + } + + #[test] + fn required_fields_apicall_rejects_missing_plugin_and_qa() { + let mut s = mk_step("fetch_issue", StepType::ApiCall); + s.api_endpoint_path = Some("/rest/api/3/issue/EW-1".into()); + // Neither api_plugin_slug nor quick_api_id set. + let err = validate_required_fields_per_type(&[s]).expect_err("must reject"); + assert!(err.contains("fetch_issue")); + assert!(err.contains("api_plugin_slug") || err.contains("quick_api_id")); + } + + #[test] + fn required_fields_apicall_accepts_quick_api_id_in_lieu_of_plugin_slug() { + let mut s = mk_step("fetch_issue", StepType::ApiCall); + s.api_endpoint_path = Some("/rest/api/3/issue/EW-1".into()); + s.quick_api_id = Some("qa-jira-fetch".into()); + validate_required_fields_per_type(&[s]).expect("QA-ref ApiCall step should validate"); + } + + #[test] + fn required_fields_apicall_accepts_complete_inline_payload() { + let mut s = mk_step("fetch_issue", StepType::ApiCall); + s.api_plugin_slug = Some("jira".into()); + s.api_endpoint_path = Some("/rest/api/3/issue/{{issue_key}}".into()); + validate_required_fields_per_type(&[s]).expect("complete inline ApiCall should validate"); + } + + #[test] + fn required_fields_batch_qp_rejects_missing_qp_id_and_items_from() { + let s = mk_step("fan_out", StepType::BatchQuickPrompt); + let err = validate_required_fields_per_type(std::slice::from_ref(&s)).expect_err("must reject"); + assert!(err.contains("batch_quick_prompt_id"), "should flag qp id first, got: {}", err); + + let mut s2 = s; + s2.batch_quick_prompt_id = Some("qp-review".into()); + let err = validate_required_fields_per_type(&[s2]).expect_err("must reject still"); + assert!(err.contains("batch_items_from")); + } + + #[test] + fn required_fields_batch_qp_accepts_complete_payload() { + let mut s = mk_step("fan_out", StepType::BatchQuickPrompt); + s.batch_quick_prompt_id = Some("qp-review".into()); + s.batch_items_from = Some("{{steps.fetch.data.tickets}}".into()); + validate_required_fields_per_type(&[s]).expect("complete BatchQuickPrompt should validate"); + } + + #[test] + fn required_fields_batch_apicall_requires_items_from_on_top_of_apicall_minimum() { + let mut s = mk_step("fan_out", StepType::BatchApiCall); + s.api_plugin_slug = Some("github".into()); + s.api_endpoint_path = Some("/repos/{owner}/{repo}".into()); + // Missing batch_items_from → reject. + let err = validate_required_fields_per_type(std::slice::from_ref(&s)).expect_err("must reject"); + assert!(err.contains("batch_items_from")); + + s.batch_items_from = Some("{{steps.fetch.data}}".into()); + validate_required_fields_per_type(&[s]).expect("complete BatchApiCall should validate"); + } + + #[test] + fn required_fields_notify_rejects_missing_config_and_empty_url() { + let s = mk_step("alert", StepType::Notify); + let err = validate_required_fields_per_type(&[s]).expect_err("must reject (no config)"); + assert!(err.contains("notify_config")); + + let mut s2 = mk_step("alert", StepType::Notify); + s2.notify_config = Some(NotifyConfig { + url: " ".into(), + method: "POST".into(), + headers: Default::default(), + body_template: "{}".into(), + }); + let err = validate_required_fields_per_type(&[s2]).expect_err("must reject (empty url)"); + assert!(err.contains("url")); + } + + #[test] + fn required_fields_gate_exec_jsondata_are_no_ops_here() { + // Those have dedicated validators (validate_exec_steps, + // validate_json_data_steps, validate_on_failure_steps for Gate- + // in-rollback). The required-fields validator must let them + // through so we don't double-report errors. + let chain = vec![ + mk_step("approve", StepType::Gate), + mk_step("run_make", StepType::Exec), + mk_step("seed", StepType::JsonData), + ]; + validate_required_fields_per_type(&chain).expect("non-API/Agent steps deferred to other validators"); + } + + #[test] + fn required_fields_first_offender_is_named_when_multiple_invalid() { + // The validator short-circuits on the first failure, which is + // the right UX: the wizard surfaces one error at a time and + // the user fixes them top-to-bottom. + let chain = vec![ + mk_step("notify_ops", StepType::Notify), // missing notify_config + mk_step("plan", StepType::Agent), // missing prompt_template + ]; + let err = validate_required_fields_per_type(&chain).expect_err("must reject"); + assert!(err.contains("notify_ops"), "first offender wins, got: {}", err); + assert!(!err.contains("plan"), "should not mention later offenders, got: {}", err); + } } diff --git a/backend/src/api_tests.rs b/backend/src/api_tests.rs index b0b9be5e..0f7241b0 100644 --- a/backend/src/api_tests.rs +++ b/backend/src/api_tests.rs @@ -1054,6 +1054,74 @@ mod tests { assert_eq!(body["data"]["steps"].as_array().unwrap().len(), 1); } + /// 0.8.5 — `workflow_create_draft` MCP tool round-trip. + /// + /// Critical safety contract: when the MCP tool POSTs with + /// `enabled: false`, the persisted workflow MUST stay disabled. + /// Without this, an agent draft would fire on its cron schedule + /// before the user has reviewed it — exactly the failure mode the + /// draft path was designed to prevent. + #[tokio::test] + async fn create_workflow_with_enabled_false_persists_as_draft() { + let state = test_state(); + let create_body = serde_json::json!({ + "name": "Draft from MCP agent", + "trigger": { "type": "Cron", "schedule": "0 9 * * 1-5" }, + "steps": [{ + "name": "s1", + "agent": "ClaudeCode", + "prompt_template": "review the staging logs", + "mode": { "type": "Normal" } + }], + "actions": [], + "enabled": false, + }); + let req = Request::builder() + .method("POST").uri("/api/workflows") + .header("Content-Type", "application/json") + .body(Body::from(create_body.to_string())).unwrap(); + let (status, body) = send(state.clone(), false, req).await; + assert_eq!(status, StatusCode::OK, "draft create: {body}"); + assert_eq!(body["data"]["enabled"].as_bool(), Some(false), + "draft workflow MUST persist with enabled=false (no auto-fire)"); + // Round-trip GET to make sure the value didn't flip on the way + // through the DB serialiser. + let wf_id = body["data"]["id"].as_str().unwrap().to_string(); + let req = Request::builder() + .method("GET").uri(format!("/api/workflows/{}", wf_id)) + .body(Body::empty()).unwrap(); + let (_, body) = send(state.clone(), false, req).await; + assert_eq!(body["data"]["enabled"].as_bool(), Some(false), + "draft persists as disabled across read"); + } + + /// 0.8.5 — back-compat: every UI-driven POST without `enabled` + /// must continue to land as `enabled: true` (the default Workflow + /// state since 0.5.x). The optional field can't accidentally + /// disable existing user flows. + #[tokio::test] + async fn create_workflow_without_enabled_field_defaults_to_true() { + let state = test_state(); + let create_body = serde_json::json!({ + "name": "UI Create", + "trigger": { "type": "Manual" }, + "steps": [{ + "name": "s1", + "agent": "ClaudeCode", + "prompt_template": "do the thing", + "mode": { "type": "Normal" } + }], + "actions": [], + }); + let req = Request::builder() + .method("POST").uri("/api/workflows") + .header("Content-Type", "application/json") + .body(Body::from(create_body.to_string())).unwrap(); + let (_, body) = send(state.clone(), false, req).await; + assert_eq!(body["data"]["enabled"].as_bool(), Some(true), + "default behaviour MUST stay enabled=true when the field is omitted (back-compat)"); + } + #[tokio::test] async fn workflows_update_and_delete() { let state = test_state(); diff --git a/backend/src/core/skills.rs b/backend/src/core/skills.rs index 7430934b..0dbec0c6 100644 --- a/backend/src/core/skills.rs +++ b/backend/src/core/skills.rs @@ -1075,4 +1075,103 @@ body"#; assert!(c.contains("Never invent a variable") || c.contains("never invent"), "qp-improver must teach the 'never invent' rule for variables / bindings"); } + + /// 0.8.5 guard: both architect skills MUST teach the new MCP + /// draft-creation tools (`workflow_create_draft` / + /// `qp_create_draft`) so the agent knows when to use the + /// autonomous fast lane vs the existing signal+button review + /// flow. Pinned after the user explicitly asked for this path + /// 2026-05-18 ("via MCP il puisse au moins faire un draft"). + #[test] + fn architect_skills_teach_mcp_draft_creation_tools() { + let skills = list_all_skills(); + + let arch = skills.iter().find(|s| s.id == "workflow-architect") + .expect("workflow-architect skill must exist"); + let c = &arch.content; + assert!(c.contains("workflow_create_draft"), + "workflow-architect skill must teach the `workflow_create_draft` MCP tool name"); + // The safety property MUST be spelled out — otherwise an agent + // might assume `enabled: true` is achievable via the draft path. + assert!( + c.contains("enabled: false") + && (c.to_lowercase().contains("forces") + || c.to_lowercase().contains("always")), + "workflow-architect must spell out the `enabled: false` safety contract on the draft path", + ); + // The signal vs MCP decision must be present so the agent + // doesn't reflexively pick MCP for every workflow design. + assert!(c.contains("workflow_create_draft") && c.contains("KRONN:WORKFLOW_READY"), + "workflow-architect must keep teaching the signal path alongside MCP — they're complementary"); + + let qp = skills.iter().find(|s| s.id == "qp-improver") + .expect("qp-improver skill must exist"); + let qpc = &qp.content; + assert!(qpc.contains("qp_create_draft"), + "qp-improver skill must teach the `qp_create_draft` MCP tool for brand-new QP creation"); + // The signal vs MCP distinction (existing QP vs new) must be + // explicit so the agent doesn't accidentally fork the user's + // current QP into a duplicate. + assert!(qpc.contains("KRONN:QP_IMPROVED") + && (qpc.to_lowercase().contains("existing qp") + || qpc.to_lowercase().contains("targets an existing")), + "qp-improver must distinguish the existing-QP signal path from the new-QP MCP path"); + // 0.8.5+: agents MUST list before creating. The skill must say + // so explicitly — otherwise the agent will reflexively call + // `qp_create_draft` and silently duplicate an existing QP. + assert!(qpc.contains("qp_list()") && qpc.to_lowercase().contains("list before"), + "qp-improver must teach 'always list before you create' via `qp_list()`"); + assert!(c.contains("workflow_list()") && c.contains("qp_list()") && c.contains("qa_list()") && c.contains("mcp_list()"), + "workflow-architect must enumerate ALL four listing tools (workflow_list / qp_list / qa_list / mcp_list) so the agent knows the read surface"); + } + + /// 0.8.5 guard: the workflow-architect skill MUST document the + /// homogenised canonical step-output envelope, otherwise + /// AI-generated workflows will keep emitting the pre-0.8.5 + /// dialects (bare JSON, no signal, etc.) and the inter-step + /// plumbing will silently drift back to two-strategy territory. + /// Pinned after the user dogfooded the EW-7247 AutoPilot run + /// 2026-05-17 and asked "are skills up to date with the new norm?" + #[test] + fn workflow_architect_skill_teaches_canonical_envelope_and_signal_coverage() { + let skills = list_all_skills(); + let arch = skills.iter().find(|s| s.id == "workflow-architect") + .expect("workflow-architect skill must exist"); + let c = &arch.content; + + // The canonical envelope must be documented with its byte-for-byte + // markers + the signal line — the agent needs both to compose + // prompts that consumers can parse. + assert!(c.contains("---STEP_OUTPUT---") && c.contains("---END_STEP_OUTPUT---"), + "skill must show the canonical `---STEP_OUTPUT---` markers"); + assert!(c.contains("[SIGNAL:"), + "skill must show the `[SIGNAL: …]` line that accompanies the canonical envelope"); + // The "every step type emits this" claim must be explicit so + // the agent stops pretending Notify / JsonData / Batch don't + // produce structured output. + assert!(c.to_lowercase().contains("every envelope-producing step type") + || c.to_lowercase().contains("every step type emits"), + "skill must declare envelope homogeneity (every envelope-producing step emits the same shape)"); + // The signals table must enumerate Notify + JsonData + BatchQuickPrompt + // as signal-emitting step types (they were "branching not supported" + // pre-0.8.5 — the user reported AI-generated workflows still + // working around that). + assert!(c.contains("`Notify`") && c.contains("[SIGNAL: ERROR]"), + "skill must teach that Notify emits `[SIGNAL: ERROR]` on non-2xx (0.8.5+)"); + assert!(c.contains("`BatchQuickPrompt`") && c.contains("[SIGNAL: PARTIAL]"), + "skill must teach that BatchQuickPrompt emits PARTIAL/OK/ERROR signals"); + // The Gate / FreeText exceptions must stay called out — they're + // the only producers without an envelope and silent drift here + // breaks downstream consumers. + assert!(c.contains("Gate") + && (c.to_lowercase().contains("no envelope") + || c.contains("**no**") + || c.contains("Gate is a pause")), + "skill must keep flagging Gate as envelope-less"); + assert!(c.contains("FreeText") + && (c.to_lowercase().contains("only `.output`") + || c.to_lowercase().contains("raw text only") + || c.contains("No envelope produced")), + "skill must keep flagging Agent FreeText as envelope-less"); + } } diff --git a/backend/src/models/setup.rs b/backend/src/models/setup.rs index 962eaf99..5ff85967 100644 --- a/backend/src/models/setup.rs +++ b/backend/src/models/setup.rs @@ -429,9 +429,20 @@ pub struct AgentDetection { fn default_true() -> bool { true } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, Default)] #[ts(export)] pub enum AgentType { + /// 0.8.5 — picked as the serde default for `WorkflowStep.agent`. The + /// field is required at runtime for agent-driven steps (Agent / + /// BatchQuickPrompt) but irrelevant for non-LLM steps (ApiCall, + /// Exec, Gate, …). Before this default the wizard had to invent a + /// placeholder agent on every ApiCall step or the JSON payload + /// failed to deserialize on `PUT /workflow-steps/test-api-call` + /// with `missing field "agent"` (caught the user during the JIRA + /// helper dogfooding on 2026-05-17). ClaudeCode is the safe pick + /// because it's the only agent guaranteed to be installed by the + /// onboarding flow. + #[default] ClaudeCode, Codex, Vibe, diff --git a/backend/src/models/workflows.rs b/backend/src/models/workflows.rs index ecac9fe4..9171edc9 100644 --- a/backend/src/models/workflows.rs +++ b/backend/src/models/workflows.rs @@ -260,8 +260,19 @@ pub struct WorkflowStep { pub step_type: StepType, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + // 0.8.5 — agent / prompt_template / mode are only meaningful for + // LLM-driven steps (Agent, BatchQuickPrompt). For ApiCall / Exec / + // Gate / Notify / JsonData / BatchApiCall steps the wizard sent + // empty / placeholder values just to satisfy serde, and a sloppy + // payload missing one of them returned a 422 with no actionable + // info ("missing field `prompt_template`"). With serde defaults + // those fields become optional in JSON; runtime validation still + // enforces them per step_type (see workflow runner dispatch). + #[serde(default)] pub agent: AgentType, + #[serde(default)] pub prompt_template: String, + #[serde(default)] pub mode: StepMode, #[serde(default)] pub output_format: StepOutputFormat, @@ -655,10 +666,16 @@ pub struct NotifyConfig { fn default_notify_method() -> String { "POST".to_string() } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] #[serde(tag = "type")] pub enum StepMode { + // 0.8.5 — `Normal` is the only variant today and is `Default` so + // `WorkflowStep` can mark `mode` as `#[serde(default)]`. Required at + // the type level for forward-compat (we plan a `Slash` variant for + // /slash-command steps in 0.9.0) but optional in JSON for clients + // that don't care (ApiCall, Exec, …). + #[default] Normal, } @@ -1001,6 +1018,16 @@ pub struct CreateWorkflowRequest { pub exec_allowlist: Vec, #[serde(default)] pub variables: Vec, + /// 0.8.5 — optional initial state. Default `true` for back-compat + /// (every UI-driven create stays enabled by default). The MCP + /// `workflow_create_draft` tool sets this to `false` so an + /// agent-spawned workflow lands in the user's Workflows page in a + /// disabled state, ready for review + manual enable. Avoids the + /// "agent just created a cron workflow that fires unattended" + /// failure mode while still letting agents accelerate the + /// adoption of Kronn by drafting common patterns autonomously. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, } #[derive(Debug, Deserialize, TS)] @@ -1174,3 +1201,84 @@ pub struct TestStepRequest { #[serde(default)] pub dry_run: bool, } + +#[cfg(test)] +mod step_deserialization_tests { + use super::*; + + /// 0.8.5 dogfooding regression test — JIRA helper case. + /// + /// The wizard's ApiCall step card composed a payload with `step_type: + /// "ApiCall"` + `api_*` fields but NO `agent` / `prompt_template` / + /// `mode`. Pre-fix serde rejected with `missing field + /// "prompt_template"` and axum returned 422 with the body as + /// text/plain — the frontend swallowed the body and the user saw a + /// bare "Server error (HTTP 422)" with no clue what was wrong. Now + /// the LLM-only fields default and an ApiCall step can be a minimal + /// JSON. The frontend api.ts also surfaces non-JSON error bodies, + /// so future serde rejects are at least diagnosable. + #[test] + fn workflow_step_apicall_deserialises_without_llm_fields() { + let json = r#"{ + "name": "fetch_issue", + "step_type": { "type": "ApiCall" }, + "api_plugin_slug": "mcp-atlassian", + "api_config_id": "cfg-1", + "api_endpoint_path": "/rest/api/3/issue/EW-7247", + "api_method": "GET" + }"#; + let step: WorkflowStep = serde_json::from_str(json) + .expect("ApiCall step with no agent/prompt/mode must deserialise"); + assert_eq!(step.name, "fetch_issue"); + assert!(matches!(step.step_type, StepType::ApiCall)); + // Defaults kicked in for the LLM-only fields. + assert_eq!(step.prompt_template, ""); + assert!(matches!(step.agent, crate::models::AgentType::ClaudeCode)); + assert!(matches!(step.mode, StepMode::Normal)); + } + + /// Agent steps still require an explicit prompt at runtime, but the + /// JSON shape stays permissive at deserialisation — runtime + /// validation lives in the workflow runner dispatch, not in serde. + /// This test pins that a fully-populated Agent step round-trips. + #[test] + fn workflow_step_agent_roundtrips_with_explicit_fields() { + let json = r#"{ + "name": "summarise", + "step_type": { "type": "Agent" }, + "agent": "Codex", + "prompt_template": "Résume {{steps.fetch.data}}", + "mode": { "type": "Normal" } + }"#; + let step: WorkflowStep = serde_json::from_str(json).expect("Agent step must deserialise"); + assert_eq!(step.prompt_template, "Résume {{steps.fetch.data}}"); + assert!(matches!(step.agent, crate::models::AgentType::Codex)); + } + + /// 422 friendliness — the test API call endpoint payload that + /// frontend `workflowsApi.testApiCall()` sends. Without + /// `prompt_template` / `agent` / `mode` defaults this test would + /// blow up with the exact error the JIRA helper agent surfaced + /// during dogfooding ("missing field `prompt_template`"). + #[test] + fn test_api_call_request_accepts_minimal_step() { + use crate::api::workflows::TestApiCallRequest; + let json = r#"{ + "step": { + "name": "fetch_issue", + "step_type": { "type": "ApiCall" }, + "api_plugin_slug": "mcp-atlassian", + "api_config_id": "cfg-1", + "api_endpoint_path": "/rest/api/3/issue/EW-{{ticket_number}}", + "api_method": "GET", + "api_query": { "fields": "summary,description" }, + "api_extract": { "path": "$.fields", "fail_on_empty": false } + }, + "project_id": "proj-1" + }"#; + let req: TestApiCallRequest = serde_json::from_str(json) + .expect("TestApiCallRequest with minimal ApiCall step must deserialise"); + assert_eq!(req.project_id, "proj-1"); + assert_eq!(req.step.name, "fetch_issue"); + } +} diff --git a/backend/src/skills/qp-improver.md b/backend/src/skills/qp-improver.md index ed276f12..8d65ab56 100644 --- a/backend/src/skills/qp-improver.md +++ b/backend/src/skills/qp-improver.md @@ -109,6 +109,33 @@ KRONN:QP_IMPROVED The frontend parses the FIRST ```json block in your reply after seeing the `KRONN:QP_IMPROVED` signal, validates it against the QuickPrompt schema, and renders a "Deploy" CTA. If the JSON is malformed, the CTA stays hidden and the user has to ask you to re-emit. +### Brand-new QPs (0.8.5+) — `qp_create_draft` MCP tool + +**Always list before you create.** Call `qp_list()` first to confirm an existing QP doesn't already cover the same use case — if it does, propose improving that one via the `KRONN:QP_IMPROVED` signal flow instead of creating a duplicate. + +The signal flow above **targets an existing QP** (the wizard fed you the QP id in the seed; the deploy CTA PUTs onto that id). If the user instead asks you to create a **brand-new** QP from a conversation (e.g. "save this prompt as a QP I can re-launch") AND no fitting QP exists in `qp_list()`, use the `qp_create_draft` MCP tool from the `kronn-internal` server: + +``` +qp_create_draft({ + name: string, // 1-200 chars, displayed on the QP card + prompt_template: string, // body with {{var}} placeholders + agent: AgentType, // ClaudeCode / Codex / Vibe / GeminiCli / Kiro / CopilotCli / Ollama / Custom + variables?: PromptVariable[], + description?: string, + icon?: string, // single emoji prefix shown on the QP card + tier?: ModelTier, // default / economy / reasoning + project_id?: string, + skill_ids?: string[], + profile_ids?: string[], + directive_ids?: string[], +}) +→ { id, name, prompt_template, ... } // the full QuickPrompt JSON +``` + +QPs have no `enabled` flag (manual launch only, no auto-fire risk), so "draft" is semantic — the agent created it, the user reviews + launches when they want. + +After calling, echo the returned `id` back to the user: `Quick Prompt drafted as — visible in your Quick Prompts tab, launch it whenever`. Don't combine with a `KRONN:QP_IMPROVED` signal in the same turn — the signal targets an existing QP, the MCP tool creates a fresh one. + ## Hard rules - **Never invent a variable** the user didn't declare unless you also drop one — keep `variables` consistent with the body's `{{vars}}`. diff --git a/backend/src/skills/workflow-architect.md b/backend/src/skills/workflow-architect.md index 1a089983..5e61d968 100644 --- a/backend/src/skills/workflow-architect.md +++ b/backend/src/skills/workflow-architect.md @@ -396,10 +396,10 @@ These shape engine behavior across the whole run. ### Template variables (any step's `prompt_template` / `notify_config.body` / `api_*` / `exec_args` / `gate_message`) -- `{{previous_step.output}}` — raw text output from the previous step -- `{{previous_step.data}}` — extracted JSON data (only if Structured Agent or ApiCall extract) -- `{{previous_step.summary}}` — one-line summary (Structured Agent only) -- `{{previous_step.status}}` — `OK`, `NO_RESULTS`, or `ERROR` (Structured Agent only) +- `{{previous_step.output}}` — raw text output from the previous step (every step type, always available) +- `{{previous_step.data}}` — structured payload. 0.8.5+: emitted by EVERY step type via the canonical Kronn envelope (see below). Exceptions: `Gate` and `Agent` with `output_format: FreeText` don't emit data, so consumers can only read `.output` from them +- `{{previous_step.summary}}` — one-line summary; same coverage as `.data` +- `{{previous_step.status}}` — `OK`, `NO_RESULTS`, `ERROR`, `PARTIAL`, `PENDING`…; same coverage as `.data` - `{{steps.STEP_NAME.output}}` — output from any named step - `{{steps.STEP_NAME.data}}` — structured/extracted data from any named step (compact JSON if object/array, raw string if `data` was a plain string) - `{{steps.STEP_NAME.data_json}}` — same data, always-serialized JSON (use when piping into an HTTP body or another JSON parser) @@ -413,12 +413,45 @@ These shape engine behavior across the whole run. ### StepOutputFormat (Agent steps only) -- **FreeText** (default) — agent produces plain text. Use for final reports, summaries. -- **Structured** — agent must produce a JSON envelope: `{"data": ..., "status": "OK|NO_RESULTS|ERROR", "summary": "..."}`. Use for inter-step data passing when the next Agent step needs specific fields. The engine auto-injects formatting instructions. +- **FreeText** (default) — agent produces plain text. Use for final reports, summaries. **No envelope produced** → downstream consumers can only read `{{steps.X.output}}`, not `.data` / `.summary` / `.status`. +- **Structured** — agent must produce a JSON envelope inside the canonical Kronn shape: `---STEP_OUTPUT---\n{"data": ..., "status": "OK|NO_RESULTS|ERROR", "summary": "..."}\n---END_STEP_OUTPUT---`. Use for inter-step data passing when the next Agent step needs specific fields. The engine auto-injects formatting instructions. - **TypedSchema** — `{ "type": "TypedSchema", "schema": { ...JSON Schema... }, "on_invalid": "Continue" | "Fail" }`. Same envelope as Structured PLUS the `data` field is validated against the JSON Schema. On validation failure, the engine fires an auto-repair retry with the schema error embedded in the prompt. The `on_invalid` flag (0.8.3) controls what happens after the repair retry still fails: - `"Continue"` (default, 0.7.0 behavior) — warn, keep the raw output, downstream steps deal with it. Safe non-breaking default. - `"Fail"` — mark the step `Failed` with the validation error as `output`. Use for **contract steps** where downstream depends on a valid shape (e.g. the Feasibility-Gated `triage` step — see § Feasibility-Gated pattern below). Without `Fail`, a malformed manifest silently propagates and the next step receives garbage. +### Canonical Kronn step-output envelope (0.8.5+) + +Since 0.8.5 EVERY envelope-producing step type emits exactly the same shape, byte-for-byte: + +``` +[optional human-readable prefix line(s)] +---STEP_OUTPUT--- +{"data": , "status": "OK|ERROR|NO_RESULTS|PARTIAL|PENDING|…", "summary": ""} +---END_STEP_OUTPUT--- +[SIGNAL: ] +[SIGNAL: ] +``` + +| Step type | Emits canonical envelope | Primary `[SIGNAL: …]` | +|---|---|---| +| `ApiCall`, `BatchApiCall` | yes | `OK` / `NO_RESULTS` / `ERROR` (+ `http_` on HTTP failure) | +| `Exec` | yes | `OK` / `ERROR` + `exit_` | +| `JsonData` | yes | `OK` | +| `Notify` | yes | `OK` / `ERROR` | +| `BatchQuickPrompt` | yes | `OK` / `PARTIAL` / `ERROR` / `PENDING` (fire-and-forget) | +| `Agent` (Structured / TypedSchema) | yes (prompt emits the markers) | whatever you tell the agent to print | +| `Agent` (FreeText) | **no** — raw text only | whatever the agent prints | +| `Gate` | **no** — the rendered `gate_message` is the output | none | + +Consumers see the same access patterns regardless of producer: +- `{{steps.X.data}}` → the JSON payload (compact for objects/arrays, string for scalars) +- `{{steps.X.data.}}` → nested traversal (dot-separated, numeric segments index arrays) +- `{{steps.X.summary}}` → the one-line summary +- `{{steps.X.status}}` → the status string +- `{{steps.X.data_json}}` → always-serialized JSON, useful for piping into an HTTP body + +If a referenced field doesn't resolve, the placeholder stays literal (`{{steps.X.data}}` rendered verbatim) AND the runner's `find_unresolved_critical_refs` fails the step fast with an actionable error — no silent data loss. For `Gate` / Agent FreeText consumers, route via `{{steps.X.output}}` only. + ### ConditionAction (in `on_result`) - `{ "type": "Stop" }` — halt the workflow (e.g., no results found) @@ -433,7 +466,10 @@ These shape engine behavior across the whole run. | `Exec` | `[SIGNAL: OK]` on exit 0, `[SIGNAL: ERROR]` on non-zero exit, plus `[SIGNAL: exit_]` for granular branching (`exit_0`, `exit_1`, `exit_2`…) | | `ApiCall` | `[SIGNAL: OK]` on 2xx, `[SIGNAL: NO_RESULTS]` when `api_extract` returns empty + `fail_on_empty`, `[SIGNAL: ERROR]` + `[SIGNAL: http_]` on HTTP error (`http_401`, `http_503`…) | | `BatchApiCall` | `[SIGNAL: OK]` if every item succeeded, `[SIGNAL: PARTIAL]` if some failed, `[SIGNAL: ERROR]` if all failed. Common pattern: `contains "PARTIAL" → Goto self (max_iterations: 2)` for transient-retry. | -| `Notify` / `Gate` / `BatchQuickPrompt` | none — branching not supported on these; rely on `on_failure` for failure handling | +| `JsonData` | `[SIGNAL: OK]` (the payload is always emitted; nothing to fail at runtime since the payload is a constant) | +| `Notify` | `[SIGNAL: OK]` on 2xx delivery, `[SIGNAL: ERROR]` on non-2xx. Useful when chaining `Notify → Notify` (primary webhook → fallback) via `contains "ERROR" → Goto fallback_notify` | +| `BatchQuickPrompt` | `[SIGNAL: OK]` if all children succeeded, `[SIGNAL: PARTIAL]` if some failed, `[SIGNAL: ERROR]` if all failed, `[SIGNAL: PENDING]` in fire-and-forget mode (`wait_for_completion: false`). Same `PARTIAL → Goto self` retry pattern as `BatchApiCall` | +| `Gate` | none — Gate is a pause, not a producer. Branch on the operator's decision via the `request_changes_target` field, not `on_result` | **`on_result` is honoured even when the step status is `Failed`** for `Exec` and `ApiCall`. This means a `Goto` rule can override the rollback chain: e.g. `cargo test` exits 1 → status `Failed`, but `contains "ERROR" → Goto implement` fires and the run continues to `implement` instead of triggering `on_failure`. If no rule matches a `Failed` step, the rollback chain fires as before. @@ -526,9 +562,50 @@ This is enforced at runtime by the triage prompt addendum + the implement step's You shouldn't hand-roll the 7 steps unless the user explicitly wants a variant. **Default: tell them about the preset.** -## Signal Protocol +## Shipping protocol + +Kronn has **three** ways to ship a workflow from a discussion. Pick by intent: + +| Path | When | UX | +|---|---|---| +| **A. `KRONN:WORKFLOW_READY` signal** | The user wants to review the JSON before deploying | Agent emits a fenced JSON + signal → frontend renders a "Create this workflow" button → user clicks → POST `/api/workflows` | +| **B. `KRONN:BUNDLE_READY` signal** | Same as A, plus the workflow needs new QPs / QAs / Custom APIs created in the same transaction | Same review flow, button reads "Create everything (1 workflow + N supporting artifacts)" → POST `/api/workflows/bundle` | +| **C. `workflow_create_draft` MCP tool (0.8.5+)** | The design has converged AND the user has indicated they want autonomous creation (e.g. "go ahead and create it" / explicit MCP usage) | Tool call → workflow lands in the user's Workflows page **in `enabled: false` state** (draft). User reviews + flips the toggle when ready. Zero cron firings before user enable. | + +**Default**: prefer **B** (`KRONN:BUNDLE_READY`) for any non-trivial workflow — the review-before-deploy flow catches mistakes the user wouldn't think to ask the agent about. Use **C** only when the user explicitly delegates autonomous creation. The two paths are complementary, not competing. + +### C. `workflow_create_draft` MCP tool (0.8.5+) — autonomous draft + +**Always list before you create.** Before drafting a brand-new workflow, call: +- `workflow_list()` — surfaces every existing workflow (id, name, enabled, project, trigger_type, step_count, last_run_status). If a fitting one already exists, propose editing it instead of duplicating. +- `qp_list()` — every Quick Prompt in the user's library. If a step in your draft could reuse an existing QP via `quick_prompt_id` / `batch_quick_prompt_id`, do that instead of inlining the same prompt. +- `qa_list()` — every Quick API. Same logic for `quick_api_id` on `ApiCall` / `BatchApiCall` steps. +- `mcp_list()` — wired MCP configs + REGISTRY servers with an `api_spec`. Use this to pick the right `api_plugin_slug` + `api_config_id` when an `ApiCall` step needs a fresh endpoint; without it the agent would have to guess slugs. + +Available via the `kronn-internal` MCP server (always wired). Signature: + +``` +workflow_create_draft({ + name: string, // 1-200 chars + trigger: WorkflowTrigger, // { "type": "Manual" } / Cron / Tracker + steps: WorkflowStep[], // 1-20 items + project_id?: string, + variables?: PromptVariable[], + guards?: WorkflowGuards, + on_failure?: WorkflowStep[], + exec_allowlist?: string[], + artifacts?: Record, + concurrency_limit?: number, + safety?: WorkflowSafety, +}) +→ { id, name, enabled: false, ... } // the full Workflow JSON +``` + +**Safety contract — the tool ALWAYS forces `enabled: false`** server-side, regardless of what the agent passes. This is the property that distinguishes autonomous draft creation from "agent fired a workflow on prod". An MCP-spawned workflow CAN'T auto-fire on its cron until the user flips the toggle. + +After calling `workflow_create_draft`, echo the returned `id` back to the user: `Workflow drafted as — review and enable in your Workflows page`. Don't also emit a `KRONN:WORKFLOW_READY` block in the same message (you'd be asking the user to deploy a workflow that's already created). -Kronn has **two** chat signals for shipping workflows from a discussion: +If the tool returns an error (validation rejection, DB error), surface the message to the user and fall back to emitting a `KRONN:WORKFLOW_READY` signal so they can fix the issue in the wizard. ### A. `KRONN:WORKFLOW_READY` — single workflow, no supporting artifacts (0.3.3+) diff --git a/backend/src/workflows/api_call_executor.rs b/backend/src/workflows/api_call_executor.rs index d5ca1aba..d4a80042 100644 --- a/backend/src/workflows/api_call_executor.rs +++ b/backend/src/workflows/api_call_executor.rs @@ -111,12 +111,32 @@ pub async fn execute_api_call_step_core( Err(e) => return fail(step, start, format!("Template render error (body): {e}")), }; + // 0.8.5 — render `{{var}}` template vars in the endpoint path FIRST. + // Pre-fix the endpoint only honoured the single-brace `{key}` form + // (resolved against `step.api_path_params`), masking and restoring + // any `{{...}}` runs verbatim. That left users who wrote + // `/rest/api/3/issue/{{issue_key}}` directly (the natural shape + // suggested by the AI helper) with a URL-encoded literal + // `%7B%7Bissue_key%7D%7D` and a confusing Jira 404. The wizard's + // helper had no way to know users had to detour through + // `api_path_params` — caught during EW-7247 AutoPilot dogfooding. + // + // Render order matters: `ctx.render()` runs first so `{{issue_key}}` + // becomes `EW-7247`, THEN `resolve_path_params` does its + // percent-encoded `{key}` pass on the result. `{{var}}` values land + // unescaped — workflow-step values are typically URL-safe (issue + // keys, project slugs, etc.); if you need percent-encoding, use the + // explicit `{key}` + `path_params` form which encodes per RFC 3986. + let templated_endpoint = match ctx.render(endpoint_path) { + Ok(s) => s, + Err(e) => return fail(step, start, format!("Endpoint template render error: {e}")), + }; // Substitute `{key}` path-segment params (e.g. /repos/{owner}/{repo}). // Values are rendered through TemplateContext FIRST so a previous // step's output can drive a segment (`{owner}` = `{{steps.X.data}}`). // Tokens with no entry stay literal — the request will then 404, // which is much more actionable than silently dropping the segment. - let resolved_path = match resolve_path_params(endpoint_path, &step.api_path_params, ctx) { + let resolved_path = match resolve_path_params(&templated_endpoint, &step.api_path_params, ctx) { Ok(p) => p, Err(e) => return fail(step, start, format!("Path param render error: {e}")), }; @@ -215,24 +235,24 @@ pub async fn execute_api_call_step_core( }; let summary = summarize(&extract_out.value, &full_url, method.as_str()); - let output_envelope = serde_json::json!({ - "data": extract_out.value, - "status": status_str, - "summary": summary, - }); - // Trailing `[SIGNAL: ]` lines so users can branch via `on_result.contains` - // without parsing the JSON envelope. NO_RESULTS gets its own signal because - // it's the existing convention for "API returned an empty list, skip the - // downstream agent" — already special-cased in the Agent path. + // 0.8.5 — emit the canonical Kronn step-output envelope (markers + + // signal) via `format_step_output`. Pre-fix this site emitted a + // bare JSON line + a single signal — extractable, but inconsistent + // with the Agent/Exec shape and a recurring source of confusion + // when wiring up cross-step references. Cf. + // [[project_step_output_homogenisation_0_9_0]] (now shipped in + // 0.8.5) and `workflows/step_output_format.rs`. let signal = if extract_out.is_empty && fail_on_empty { - "[SIGNAL: NO_RESULTS]" + "NO_RESULTS" } else { - "[SIGNAL: OK]" + "OK" }; - let output = format!( - "{}\n{}", - serde_json::to_string(&output_envelope).unwrap_or_default(), - signal, + let output = super::step_output_format::format_step_output( + extract_out.value.clone(), + status_str, + &summary, + None, + &[signal], ); let condition_action = super::steps::evaluate_conditions(&step.on_result, &output); let condition_result = condition_action.as_ref().map(|a| match a { @@ -1169,12 +1189,10 @@ mod tests { } fn extract_envelope(output: &str) -> Value { - // The runtime output is `{json}\n[SIGNAL: ...]` (the trailing - // SIGNAL line lets `evaluate_conditions` branch without parsing - // JSON). Parse only the JSON head — split on the SIGNAL marker - // if present, otherwise the whole string. - let json_part = output.split("\n[SIGNAL:").next().unwrap_or(output); - serde_json::from_str(json_part).expect("output is structured JSON") + // 0.8.5 — outputs now go through the canonical Kronn envelope + // (markers + signals). Reuse the shared test helper so a future + // tweak in the format only touches one place. + super::super::step_output_format::parse_envelope_for_test(output) } // ─── resolve_auth ─────────────────────────────────────────────── @@ -1396,6 +1414,64 @@ mod tests { assert_eq!(out, "/repos/x/{repo}"); } + // ─── 0.8.5 regression — `{{var}}` MUST resolve in endpoint path ── + // + // Pre-fix the executor only ran `resolve_path_params` on the + // endpoint, which deliberately masked + restored `{{...}}` runs. + // Workflows that wrote `/rest/api/3/issue/{{issue_key}}` directly + // (the natural shape suggested by the AI helper) fired with a + // URL-encoded literal `%7B%7Bissue_key%7D%7D` → Jira 404. The fix + // runs `ctx.render()` on the endpoint BEFORE + // `resolve_path_params`. These tests pin the combined pipeline. + + #[test] + fn endpoint_double_brace_var_is_substituted_by_ctx_render_then_path_params() { + let mut ctx = TemplateContext::new(); + ctx.set("issue_key", "EW-7247"); + + // What execute_api_call_step_core now does: + let templated = ctx.render("/rest/api/3/issue/{{issue_key}}").unwrap(); + let resolved = resolve_path_params(&templated, &None, &ctx).unwrap(); + assert_eq!(resolved, "/rest/api/3/issue/EW-7247"); + } + + #[test] + fn endpoint_double_brace_var_works_with_step_outputs() { + let mut ctx = TemplateContext::new(); + // Mimic what `inject_trigger_context` does for issue.* fields. + ctx.set("issue.title", "Hello world"); + let templated = ctx.render("/api/echo/{{issue.title}}").unwrap(); + let resolved = resolve_path_params(&templated, &None, &ctx).unwrap(); + // No path_params → ctx.render alone is the substitution path. + // Note: spaces aren't percent-encoded here (caller's burden) — + // that's the documented trade-off in the executor comment. + assert_eq!(resolved, "/api/echo/Hello world"); + } + + #[test] + fn endpoint_double_brace_var_unknown_stays_literal_after_render() { + // Unknown {{var}} → ctx.render leaves it literal (existing + // contract); resolve_path_params then has nothing to do. + let ctx = TemplateContext::new(); + let templated = ctx.render("/items/{{nope}}").unwrap(); + let resolved = resolve_path_params(&templated, &None, &ctx).unwrap(); + assert_eq!(resolved, "/items/{{nope}}"); + } + + #[test] + fn endpoint_supports_both_double_and_single_brace_forms() { + // Mixed form: `{{var}}` for ctx, `{key}` for path_params. + // ctx.render resolves the double-brace, then resolve_path_params + // handles the single-brace. + let mut ctx = TemplateContext::new(); + ctx.set("base", "v3"); + let mut path_params = HashMap::new(); + path_params.insert("issue_id".to_string(), "EW-1".to_string()); + let templated = ctx.render("/rest/api/{{base}}/issue/{issue_id}").unwrap(); + let resolved = resolve_path_params(&templated, &Some(path_params), &ctx).unwrap(); + assert_eq!(resolved, "/rest/api/v3/issue/EW-1"); + } + #[test] fn path_params_dont_match_double_brace_template_vars() { // `{{steps.X.data}}` is the template-var syntax, not a path diff --git a/backend/src/workflows/batch_apicall_step.rs b/backend/src/workflows/batch_apicall_step.rs index 48b76b9c..003f9efc 100644 --- a/backend/src/workflows/batch_apicall_step.rs +++ b/backend/src/workflows/batch_apicall_step.rs @@ -215,25 +215,26 @@ pub async fn execute_batch_apicall_step( else if succeeded == 0 { "ERROR" } else { "PARTIAL" }; let summary = format!("BatchApiCall: {succeeded}/{total} succeeded ({failed} failed)"); - let envelope = serde_json::json!({ - "data": { + // 0.8.5 — canonical envelope via shared formatter (markers + signal). + // Signal name matches `aggregate_status` so `on_result.contains` + // rules can branch via "OK" / "PARTIAL" / "ERROR" without parsing + // the JSON. Cf. [[project_step_output_homogenisation_0_9_0]]. + let signal = match aggregate_status { + "OK" => "OK", + "PARTIAL" => "PARTIAL", + _ => "ERROR", + }; + let output = super::step_output_format::format_step_output( + serde_json::json!({ "items": items_json, "total": total, "succeeded": succeeded, "failed": failed, - }, - "status": aggregate_status, - "summary": summary, - }); - let signal = match aggregate_status { - "OK" => "[SIGNAL: OK]", - "PARTIAL" => "[SIGNAL: PARTIAL]", - _ => "[SIGNAL: ERROR]", - }; - let output = format!( - "{}\n{}", - serde_json::to_string(&envelope).unwrap_or_default(), - signal, + }), + aggregate_status, + &summary, + None, + &[signal], ); let condition_action = super::steps::evaluate_conditions(&step.on_result, &output); diff --git a/backend/src/workflows/batch_step.rs b/backend/src/workflows/batch_step.rs index 7e91ec78..cffc65ac 100644 --- a/backend/src/workflows/batch_step.rs +++ b/backend/src/workflows/batch_step.rs @@ -534,11 +534,18 @@ fn build_structured_output( ), ); - serde_json::json!({ - "data": data, - "status": status, - "summary": summary, - }).to_string() + // 0.8.5 — canonical Kronn envelope (markers + signal) via the + // shared formatter. The signal matches the status so consumers can + // branch via `on_result: [{ contains: "PARTIAL", action: Skip }]` + // or similar without re-parsing the JSON. Cf. + // [[project_step_output_homogenisation_0_9_0]]. + super::step_output_format::format_step_output( + serde_json::to_value(&data).unwrap_or(serde_json::Value::Null), + status, + &summary, + None, + &[status], + ) } #[cfg(test)] @@ -627,37 +634,48 @@ mod tests { assert_eq!(out, "Static template"); } + // 0.8.5 — outputs go through the canonical Kronn envelope + // (markers + signal). These tests parse via `parse_envelope_for_test` + // so a future format tweak only changes the helper, not 20 tests. + use super::super::step_output_format::parse_envelope_for_test; + #[test] fn build_structured_output_ok() { let out = build_structured_output("run-1", 3, 3, 0, &["d1".into(), "d2".into(), "d3".into()], true); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let v = parse_envelope_for_test(&out); assert_eq!(v["status"], "OK"); assert_eq!(v["data"]["total"], 3); assert_eq!(v["data"]["ok"], 3); assert_eq!(v["data"]["failed"], 0); assert_eq!(v["data"]["batch_run_id"], "run-1"); assert_eq!(v["data"]["discussion_ids"].as_array().unwrap().len(), 3); + // Canonical envelope must also carry the matching SIGNAL line so + // `on_result.contains` rules can branch on status. + assert!(out.contains("[SIGNAL: OK]")); } #[test] fn build_structured_output_partial() { let out = build_structured_output("run-1", 5, 3, 2, &[], true); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let v = parse_envelope_for_test(&out); assert_eq!(v["status"], "PARTIAL"); + assert!(out.contains("[SIGNAL: PARTIAL]")); } #[test] fn build_structured_output_error() { let out = build_structured_output("run-1", 5, 0, 5, &[], true); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let v = parse_envelope_for_test(&out); assert_eq!(v["status"], "ERROR"); + assert!(out.contains("[SIGNAL: ERROR]")); } #[test] fn build_structured_output_pending_fire_and_forget() { let out = build_structured_output("run-1", 5, 0, 0, &[], false); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let v = parse_envelope_for_test(&out); assert_eq!(v["status"], "PENDING"); + assert!(out.contains("[SIGNAL: PENDING]")); } // ─── E2E tests for `execute_batch_quick_prompt_step` ───────────────────── @@ -848,8 +866,7 @@ mod tests { // ─ Step-level assertions assert_eq!(outcome.result.status, RunStatus::Success, "fire-and-forget should always return Success once the spawn loop fires"); - let envelope: serde_json::Value = serde_json::from_str(&outcome.result.output) - .expect("structured output is JSON"); + let envelope = parse_envelope_for_test(&outcome.result.output); assert_eq!(envelope["status"], "PENDING", "wait_for_completion=false must produce PENDING (caller knows it's racing)"); let data = &envelope["data"]; @@ -937,8 +954,7 @@ mod tests { // ─ The wait completed and propagated the counters from our synthesized event. assert_eq!(outcome.result.status, RunStatus::Success, "all 3 children OK → step Success (success = at-least-one OK)"); - let envelope: serde_json::Value = serde_json::from_str(&outcome.result.output) - .expect("structured output is JSON"); + let envelope = parse_envelope_for_test(&outcome.result.output); assert_eq!(envelope["status"], "OK", "3 ok / 0 failed → OK envelope"); assert_eq!(envelope["data"]["total"], 3); assert_eq!(envelope["data"]["ok"], 3); diff --git a/backend/src/workflows/big_ticket_template.rs b/backend/src/workflows/big_ticket_template.rs index 1e276374..02486dcc 100644 --- a/backend/src/workflows/big_ticket_template.rs +++ b/backend/src/workflows/big_ticket_template.rs @@ -118,6 +118,11 @@ pub fn build_feasibility_workflow(params: FeasibilityWorkflowParams) -> CreateWo // wired in here, otherwise the validator rejects the workflow. exec_allowlist: EXEC_ALLOWLIST.iter().map(|s| s.to_string()).collect(), variables: vec![], + // 0.8.5 — `enabled: None` lets the handler default to true (the + // big-ticket template path is "user clicked the button to create + // this", not "agent autonomously drafted it" — same semantics as + // every UI-driven create). + enabled: None, } } diff --git a/backend/src/workflows/json_data_step.rs b/backend/src/workflows/json_data_step.rs index a1bde2d2..0f18b1d0 100644 --- a/backend/src/workflows/json_data_step.rs +++ b/backend/src/workflows/json_data_step.rs @@ -64,12 +64,18 @@ pub async fn execute_json_data_step(step: &WorkflowStep) -> StepOutcome { let summary = build_summary(&payload); - let output_json = serde_json::json!({ - "data": payload, - "status": "OK", - "summary": summary, - }); - let output = serde_json::to_string(&output_json).unwrap_or_else(|_| String::from("{}")); + // 0.8.5 — canonical envelope via shared formatter. Pre-fix this + // step emitted bare compact JSON, which `extract_step_envelope` + // absorbed via the strategy-2 fallback. The unified shape (with + // `---STEP_OUTPUT---` markers + `[SIGNAL: OK]`) means consumers + // and the run-log viewer see the same structure regardless of + // which step type produced the data. Cf. + // [[project_step_output_homogenisation_0_9_0]]. + let output = super::step_output_format::format_step_output_simple( + payload, + "OK", + &summary, + ); StepOutcome { result: StepResult { @@ -204,8 +210,8 @@ mod tests { let outcome = execute_json_data_step(&step).await; assert_eq!(outcome.result.status, RunStatus::Success); - let envelope: serde_json::Value = - serde_json::from_str(&outcome.result.output).expect("output is JSON"); + let envelope = + crate::workflows::step_output_format::parse_envelope_for_test(&outcome.result.output); assert_eq!(envelope["status"], "OK"); assert_eq!(envelope["data"], payload); assert!( @@ -226,8 +232,8 @@ mod tests { let outcome = execute_json_data_step(&step).await; assert_eq!(outcome.result.status, RunStatus::Success); - let envelope: serde_json::Value = - serde_json::from_str(&outcome.result.output).expect("output is JSON"); + let envelope = + crate::workflows::step_output_format::parse_envelope_for_test(&outcome.result.output); assert_eq!(envelope["data"], payload); let summary = envelope["summary"].as_str().unwrap(); assert!(summary.contains("3"), "summary mentions field count"); @@ -243,8 +249,8 @@ mod tests { let step = blank_step(Some(payload.clone())); let outcome = execute_json_data_step(&step).await; assert_eq!(outcome.result.status, RunStatus::Success); - let envelope: serde_json::Value = - serde_json::from_str(&outcome.result.output).expect("output is JSON"); + let envelope = + crate::workflows::step_output_format::parse_envelope_for_test(&outcome.result.output); assert_eq!(envelope["data"], payload); } @@ -259,8 +265,8 @@ mod tests { }); let step = blank_step(Some(payload.clone())); let outcome = execute_json_data_step(&step).await; - let envelope: serde_json::Value = - serde_json::from_str(&outcome.result.output).expect("output is JSON"); + let envelope = + crate::workflows::step_output_format::parse_envelope_for_test(&outcome.result.output); assert_eq!( envelope["data"]["raw_template"], "{{not_substituted}}", "templates are NOT rendered in JsonData payloads" diff --git a/backend/src/workflows/mod.rs b/backend/src/workflows/mod.rs index fc902e14..20e5d17d 100644 --- a/backend/src/workflows/mod.rs +++ b/backend/src/workflows/mod.rs @@ -4,6 +4,7 @@ //! and spawns runs. pub mod big_ticket_template; +pub mod step_output_format; pub mod template; pub mod triage; pub mod workspace; diff --git a/backend/src/workflows/notify_step.rs b/backend/src/workflows/notify_step.rs index e41861cc..6db4517d 100644 --- a/backend/src/workflows/notify_step.rs +++ b/backend/src/workflows/notify_step.rs @@ -97,22 +97,24 @@ pub async fn execute_notify_step( let is_success = status.is_success(); // ── Build structured output so downstream steps can chain on data ─── - let output_json = serde_json::json!({ - "data": { + // 0.8.5 — canonical envelope via shared formatter. Always emits + // `[SIGNAL: OK|ERROR]` so `on_result` rules can branch on the + // delivery success without parsing the JSON. Cf. + // [[project_step_output_homogenisation_0_9_0]]. + let status_str = if is_success { "OK" } else { "ERROR" }; + let summary = format!("{} {} → {}", method.as_str(), url, http_status); + let output = super::step_output_format::format_step_output( + serde_json::json!({ "http_status": http_status, "response_excerpt": excerpt, "url": url, "method": method.as_str(), - }, - "status": if is_success { "OK" } else { "ERROR" }, - "summary": format!( - "{} {} → {}", - method.as_str(), - url, - http_status - ), - }); - let output = serde_json::to_string(&output_json).unwrap_or_default(); + }), + status_str, + &summary, + None, + &[status_str], + ); StepOutcome { result: StepResult { @@ -303,7 +305,7 @@ mod tests { assert!(received_body.lock().await.contains(r#""stage":"plan_ready""#)); // Structured output carries http_status + summary for downstream chaining - let parsed: serde_json::Value = serde_json::from_str(&out.result.output).unwrap(); + let parsed = crate::workflows::step_output_format::parse_envelope_for_test(&out.result.output); assert_eq!(parsed["status"], "OK"); assert_eq!(parsed["data"]["http_status"], 200); assert!(parsed["summary"].as_str().unwrap().contains("200")); @@ -334,7 +336,7 @@ mod tests { }); let out = execute_notify_step(&step, &TemplateContext::new()).await; assert_eq!(out.result.status, RunStatus::Failed); - let parsed: serde_json::Value = serde_json::from_str(&out.result.output).unwrap(); + let parsed = crate::workflows::step_output_format::parse_envelope_for_test(&out.result.output); assert_eq!(parsed["status"], "ERROR"); assert_eq!(parsed["data"]["http_status"], 400); assert!(parsed["data"]["response_excerpt"].as_str().unwrap().contains("nope, bad payload")); diff --git a/backend/src/workflows/step_output_format.rs b/backend/src/workflows/step_output_format.rs new file mode 100644 index 00000000..f53a1d3a --- /dev/null +++ b/backend/src/workflows/step_output_format.rs @@ -0,0 +1,224 @@ +// 0.8.5 — canonical step-output envelope. +// +// **Why this module exists.** Pre-refactor every step type emitted its +// own variant of "structured output": +// - Agent (Structured/TypedSchema) + Exec → `---STEP_OUTPUT---\n{…}\n---END_STEP_OUTPUT---` +// - ApiCall → `{…}\n[SIGNAL: OK]` (bare JSON + signal) +// - JsonData / Notify / Batch* → `{…}` (bare JSON, no signal) +// The runner's `extract_step_envelope` tried two strategies (markers +// first, last bare JSON with `data`+`status` second) to absorb the +// variance, but the duplication was a real regression surface — +// witnessed by the EW-7247 AutoPilot dogfooding on 2026-05-17 where +// `{{steps.fetch_issue.data.body}}` failed because the consumer +// assumed a JsonData shape but the producer was an ApiCall. +// +// **The canonical Kronn step-output envelope** (this module's product): +// +// ```text +// +// ---STEP_OUTPUT--- +// { "data": , "status": "OK"|"ERROR"|"NO_RESULTS"|…, "summary": "" } +// ---END_STEP_OUTPUT--- +// [SIGNAL: ] +// [SIGNAL: ] +// ``` +// +// Every envelope-producing step type now funnels through +// `format_step_output` so the markers + signals are emitted with +// byte-for-byte consistency. Consumers reach into the result via +// `TemplateContext::set_step_output` which still keeps the +// strategy-2 fallback for legacy records loaded from DB. +// +// **Exceptions** (no envelope, intentional): +// - Gate steps emit their rendered `gate_message` verbatim — the +// step has no semantic data to pass downstream, it's a pause. +// - Agent steps with `output_format: FreeText` emit raw text — the +// user opted out of structure and the save-time validator catches +// any consumer that tries to read `.data` from it. + +use serde_json::{json, Value}; + +/// Build a Kronn step-output envelope. +/// +/// - `data` is the step's semantic payload (any JSON value). +/// - `status` is one of `"OK"`, `"ERROR"`, `"NO_RESULTS"`, or a custom +/// code if the step type needs a richer signal vocabulary. +/// - `summary` is a one-line human-readable description (used in run +/// logs and as the consumer-facing `{{steps.X.summary}}` value). +/// - `prefix` is optional human text rendered BEFORE the envelope — +/// useful for step types that want operators to see a friendly +/// one-liner above the JSON when expanding a run row. +/// - `signals` is the trailing `[SIGNAL: …]` line(s). Always at least +/// one (e.g. `"OK"`). Multiple signals stack on separate lines. +/// +/// The returned string is what each step type writes to +/// `StepResult.output`. The runner's `set_step_output` then extracts +/// the envelope via the strategy-1 markers — guaranteed match. +pub fn format_step_output( + data: Value, + status: &str, + summary: &str, + prefix: Option<&str>, + signals: &[&str], +) -> String { + let envelope = json!({ + "data": data, + "status": status, + "summary": summary, + }); + // Compact JSON is intentional: the envelope is consumed by + // machines (`set_step_output` → JSON parse). Pretty printing + // would inflate the run log size without helping operators + // (who read the `prefix` line and the SIGNAL lines, not the JSON). + let envelope_str = serde_json::to_string(&envelope).unwrap_or_else(|_| "{}".to_string()); + + let mut out = String::with_capacity(envelope_str.len() + 128); + if let Some(p) = prefix { + if !p.is_empty() { + out.push_str(p); + // Blank line between human prefix and the marker block so + // operators can visually parse the structure. + if !p.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + } + } + out.push_str("---STEP_OUTPUT---\n"); + out.push_str(&envelope_str); + out.push_str("\n---END_STEP_OUTPUT---\n"); + for sig in signals { + out.push_str("[SIGNAL: "); + out.push_str(sig); + out.push_str("]\n"); + } + // Trim trailing newline — `evaluate_conditions` reads the last 5 + // lines, and a trailing empty line would push a meaningful SIGNAL + // out of the window. + while out.ends_with('\n') { + out.pop(); + } + out +} + +/// Convenience constructor when there's no prefix and a single signal +/// (the common case for JsonData, Notify, batch fan-outs). +pub fn format_step_output_simple(data: Value, status: &str, summary: &str) -> String { + format_step_output(data, status, summary, None, &[status]) +} + +/// Test-only helper: parse the canonical envelope produced by +/// `format_step_output` into a `serde_json::Value` shaped +/// `{ data, status, summary }`. Existing step-type tests used to +/// `serde_json::from_str(&output)` directly on the bare JSON line — +/// after the 0.8.5 homogenisation that fails because the output now +/// has markers + signals around it. Wrapping the extraction here +/// keeps test bodies short and the parsing logic in one place. +/// +/// Panics if the output doesn't contain a canonical envelope — +/// regression tests want a loud failure, not silent `None`. +#[cfg(test)] +pub fn parse_envelope_for_test(output: &str) -> Value { + let env = crate::workflows::template::extract_step_envelope(output) + .expect("output must contain a parseable Kronn envelope (---STEP_OUTPUT--- markers or strategy-2 JSON)"); + let data: Value = serde_json::from_str(&env.data_json) + .unwrap_or(Value::Null); + serde_json::json!({ + "data": data, + "status": env.status, + "summary": env.summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_step_output_emits_canonical_markers() { + let s = format_step_output( + json!({ "key": "EW-1" }), + "OK", + "1 issue fetched", + None, + &["OK"], + ); + assert!(s.contains("---STEP_OUTPUT---")); + assert!(s.contains("---END_STEP_OUTPUT---")); + assert!(s.contains("[SIGNAL: OK]")); + // No trailing blank line — would shift signals out of the + // last-5-lines window scanned by evaluate_conditions. + assert!(!s.ends_with('\n')); + } + + #[test] + fn format_step_output_respects_prefix_with_blank_line_separator() { + let s = format_step_output( + json!({}), + "OK", + "done", + Some("HTTP 200 — fetched 1 issue"), + &["OK"], + ); + // Prefix is on its own line(s), followed by a blank line, then the marker. + assert!(s.starts_with("HTTP 200 — fetched 1 issue\n\n---STEP_OUTPUT---\n")); + } + + #[test] + fn format_step_output_supports_multiple_signals() { + // Exec emits both a generic (OK/ERROR) signal AND a specific + // exit_ signal so users can branch on either. + let s = format_step_output( + json!({ "exit_code": 2 }), + "ERROR", + "exec exit 2", + None, + &["ERROR", "exit_2"], + ); + assert!(s.contains("[SIGNAL: ERROR]")); + assert!(s.contains("[SIGNAL: exit_2]")); + // Signals come at the END (last 5 lines per evaluate_conditions). + let tail: Vec<&str> = s.lines().rev().take(5).collect(); + assert!(tail.iter().any(|l| l.contains("[SIGNAL: ERROR]"))); + assert!(tail.iter().any(|l| l.contains("[SIGNAL: exit_2]"))); + } + + #[test] + fn format_step_output_envelope_extractor_can_parse_output() { + // End-to-end pin: a Kronn envelope produced by this module + // MUST round-trip through `extract_step_envelope` cleanly. + // Regression here would break every step→step plumbing. + let s = format_step_output( + json!({ "key": "EW-7247", "fields": { "summary": "Hello" } }), + "OK", + "GET /issue → 1 item", + Some("HTTP 200"), + &["OK"], + ); + let env = crate::workflows::template::extract_step_envelope(&s) + .expect("canonical envelope must be parseable"); + assert_eq!(env.status, "OK"); + assert_eq!(env.summary, "GET /issue → 1 item"); + // data_json round-trips through serde — pin a substring rather + // than the exact byte string so future helper tweaks (key + // ordering, etc.) don't break the test. + assert!(env.data_json.contains("EW-7247")); + assert!(env.data_json.contains("Hello")); + } + + #[test] + fn format_step_output_simple_is_equivalent_to_full_with_default_signal() { + let a = format_step_output_simple(json!({ "x": 1 }), "OK", "done"); + let b = format_step_output(json!({ "x": 1 }), "OK", "done", None, &["OK"]); + assert_eq!(a, b); + } + + #[test] + fn format_step_output_handles_empty_data_object() { + let s = format_step_output(json!({}), "OK", "noop", None, &["OK"]); + let env = crate::workflows::template::extract_step_envelope(&s).unwrap(); + assert_eq!(env.status, "OK"); + // Empty object renders as `{}` in JSON — `.data` stringification. + assert_eq!(env.data_json, "{}"); + } +} diff --git a/backend/src/workflows/template.rs b/backend/src/workflows/template.rs index 5e33d08e..5410dac5 100644 --- a/backend/src/workflows/template.rs +++ b/backend/src/workflows/template.rs @@ -1850,4 +1850,538 @@ mod tests { let rendered = ctx.render("{{state.last_verdict}}").unwrap(); assert_eq!(rendered, "approved"); } + + // ════════════════════════════════════════════════════════════════════ + // 0.8.5 — Cross-step output transmission matrix + // + // CRITICAL plumbing layer. Every step type publishes its output as a + // string to `set_step_output(name, output)`. Downstream steps read + // it via templates like `{{steps.X.data}}`, `{{steps.X.summary}}`, + // `{{steps.X.status}}`, `{{steps.X.data.}}`. The pin below + // captures, for EACH step type, a representative output sample + // (mirroring exactly what each step implementation emits today) and + // verifies the envelope extraction + the four canonical access + // patterns. Any regression in a step's output shape — or in the + // envelope extractor — fails one localised test instead of + // silently breaking every workflow that consumes it. + // + // Source-of-truth samples (kept verbatim with the producing impl): + // - JsonData → `json_data_step.rs::execute_json_data_step` + // - ApiCall → `api_call_executor.rs::execute_api_call_step_core` + // - Notify → `notify_step.rs::execute_notify_step` + // - Exec → `exec_step.rs::execute_exec_step` + // - Agent (Struct.) → runner.rs emits `---STEP_OUTPUT---` envelope + // - Agent (FreeText)→ raw text, no envelope (consumers can only + // read `.output`) + // - Gate → raw rendered gate_message, no envelope + // - BatchApiCall → `build_structured_output` (strategy-2 JSON) + // - BatchQuickPrompt→ `build_structured_output` (strategy-2 JSON) + // + // If you add a new step type, add a sample + assertion block below. + // ════════════════════════════════════════════════════════════════════ + mod cross_step_transmission { + use super::*; + + // ── JsonData ──────────────────────────────────────────────────── + fn sample_json_data_output() -> String { + // 0.8.5 — JsonData now emits the canonical Kronn envelope + // (markers + signal) via `format_step_output_simple`. The + // legacy bare-JSON shape is covered by the dedicated + // backward-compat test further down. + crate::workflows::step_output_format::format_step_output_simple( + serde_json::json!({ "key": "DEMO-1", "body": "Refactor login button" }), + "OK", + "JSON data (1 object, 2 field(s))", + ) + } + + #[test] + fn json_data_exposes_data_summary_status_and_nested_fields() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fetch", &sample_json_data_output()); + + // Top-level envelope fields land in ctx. + assert_eq!( + ctx.render("{{steps.fetch.status}}").unwrap(), + "OK", + ); + assert_eq!( + ctx.render("{{steps.fetch.summary}}").unwrap(), + "JSON data (1 object, 2 field(s))", + ); + // `data` (object) renders as compact JSON via stringify path. + assert!(ctx.render("{{steps.fetch.data}}").unwrap().contains("DEMO-1")); + // Nested traversal works (resolve_nested_path via data_json). + assert_eq!( + ctx.render("{{steps.fetch.data.key}}").unwrap(), + "DEMO-1", + ); + assert_eq!( + ctx.render("{{steps.fetch.data.body}}").unwrap(), + "Refactor login button", + ); + // `previous_step.*` aliases mirror the named ones. + assert_eq!( + ctx.render("{{previous_step.status}}").unwrap(), + "OK", + ); + } + + // ── ApiCall (Jira-shaped data) ────────────────────────────────── + fn sample_apicall_jira_output() -> String { + // 0.8.5 — ApiCall now goes through `format_step_output` like + // every other step type. The Jira-shaped data inside is the + // realistic AutoCode `fetch_issue` payload — what consumers + // see when navigating `{{steps.fetch_issue.data.}}`. + crate::workflows::step_output_format::format_step_output( + serde_json::json!({ + "key": "EW-7247", + "fields": { + "summary": "Africanews → Euronews migration", + "description": { "content": [], "type": "doc" }, + }, + "renderedFields": { + "description": "

    Port Africanews onto Euronews…

    ", + }, + }), + "OK", + "GET /rest/api/3/issue/EW-7247 → 1 item", + None, + &["OK"], + ) + } + + #[test] + fn apicall_exposes_nested_path_into_real_jira_payload() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fetch_issue", &sample_apicall_jira_output()); + + // The fix that unblocked AutoCode EW-7247 pinned: data.key reachable. + assert_eq!( + ctx.render("{{steps.fetch_issue.data.key}}").unwrap(), + "EW-7247", + ); + // Deep nested traversal — `data.fields.summary`. + assert_eq!( + ctx.render("{{steps.fetch_issue.data.fields.summary}}").unwrap(), + "Africanews → Euronews migration", + ); + // `data.renderedFields.description` (Jira HTML body). + assert_eq!( + ctx.render("{{steps.fetch_issue.data.renderedFields.description}}").unwrap(), + "

    Port Africanews onto Euronews…

    ", + ); + // Bare `.data` returns compact JSON (downstream agent can navigate). + let bare = ctx.render("{{steps.fetch_issue.data}}").unwrap(); + assert!(bare.contains("EW-7247")); + assert!(bare.contains("Africanews")); + } + + // ── Notify ────────────────────────────────────────────────────── + fn sample_notify_output() -> String { + // 0.8.5 — Notify now emits the canonical envelope + signal. + crate::workflows::step_output_format::format_step_output( + serde_json::json!({ + "http_status": 200, + "response_excerpt": "{\"ok\": true}", + "url": "https://hooks.example/abc", + "method": "POST", + }), + "OK", + "POST https://hooks.example/abc → 200", + None, + &["OK"], + ) + } + + #[test] + fn notify_exposes_http_metadata_to_downstream_steps() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("alert", &sample_notify_output()); + assert_eq!( + ctx.render("{{steps.alert.status}}").unwrap(), + "OK", + ); + assert_eq!( + ctx.render("{{steps.alert.data.http_status}}").unwrap(), + "200", + ); + assert_eq!( + ctx.render("{{steps.alert.data.url}}").unwrap(), + "https://hooks.example/abc", + ); + } + + // ── Exec ──────────────────────────────────────────────────────── + fn sample_exec_output(exit_code: i32, stdout: &str) -> String { + // Mirrors exec_step.rs:324-334 — Strategy-1 (`---STEP_OUTPUT---`) + // wrapped JSON envelope, plus trailing `[SIGNAL: …]` lines. + let env = serde_json::json!({ + "data": { + "exit_code": exit_code, + "stdout_excerpt": stdout, + "stderr_excerpt": "", + "killed": false, + }, + "status": if exit_code == 0 { "OK" } else { "ERROR" }, + "summary": format!("exec exit {}", exit_code), + }); + let signal_generic = if exit_code == 0 { "[SIGNAL: OK]" } else { "[SIGNAL: ERROR]" }; + format!( + "summary line\n\n---STEP_OUTPUT---\n{}\n---END_STEP_OUTPUT---\n{}\n[SIGNAL: exit_{}]", + env, signal_generic, exit_code, + ) + } + + #[test] + fn exec_exposes_exit_code_and_stdout_excerpt() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("tests", &sample_exec_output(0, "test passed")); + assert_eq!( + ctx.render("{{steps.tests.status}}").unwrap(), + "OK", + ); + assert_eq!( + ctx.render("{{steps.tests.data.exit_code}}").unwrap(), + "0", + ); + assert_eq!( + ctx.render("{{steps.tests.data.stdout_excerpt}}").unwrap(), + "test passed", + ); + } + + #[test] + fn exec_failure_envelope_still_extracts_data() { + // Regression: even on Failed status, set_step_output must + // populate ctx so downstream conditional steps can branch. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("tests", &sample_exec_output(2, "compile error")); + assert_eq!( + ctx.render("{{steps.tests.status}}").unwrap(), + "ERROR", + ); + assert_eq!( + ctx.render("{{steps.tests.data.exit_code}}").unwrap(), + "2", + ); + } + + // ── Agent (Structured) ────────────────────────────────────────── + fn sample_agent_structured_output(data: serde_json::Value, summary: &str) -> String { + // Mirrors what the runner expects an Agent in `output_format: + // Structured` to emit — a `---STEP_OUTPUT---` block at the + // end of the response. Strategy-1 parseable. + format!( + "Here is my analysis.\n\n---STEP_OUTPUT---\n{}\n---END_STEP_OUTPUT---", + serde_json::json!({ "data": data, "status": "OK", "summary": summary }), + ) + } + + #[test] + fn agent_structured_exposes_typed_manifest_fields() { + // The AutoCode triage step emits a typed manifest. Pin the + // contract that `implement` can read each branch array. + let manifest = serde_json::json!({ + "clear": ["sub-1", "sub-2"], + "decided": [{ "id": "d1", "chosen": "A" }], + "mocked": [], + "blocked": [{ "id": "b1", "needed_from": "PM" }], + "files_touched": ["src/foo.rs", "src/bar.rs"], + }); + let mut ctx = TemplateContext::new(); + ctx.set_step_output( + "triage", + &sample_agent_structured_output(manifest, "5 entries triaged"), + ); + + // Nested arrays render as pretty JSON (operator-friendly). + let clear = ctx.render("{{steps.triage.data.clear}}").unwrap(); + assert!(clear.contains("sub-1")); + assert!(clear.contains("sub-2")); + // Nested object inside an array. + let decided = ctx.render("{{steps.triage.data.decided}}").unwrap(); + assert!(decided.contains("\"id\": \"d1\"")); + // `summary` and `status` reachable in the same envelope. + assert_eq!( + ctx.render("{{steps.triage.summary}}").unwrap(), + "5 entries triaged", + ); + assert_eq!( + ctx.render("{{steps.triage.status}}").unwrap(), + "OK", + ); + // Empty array still renders. + assert_eq!( + ctx.render("{{steps.triage.data.mocked}}").unwrap(), + "[]", + ); + } + + // ── Agent (FreeText) ──────────────────────────────────────────── + // FreeText output has NO envelope. Downstream consumers can only + // read `{{steps.X.output}}` — `.data` / `.summary` / `.status` + // remain unresolved (intentional: validate_step_references catches + // mismatches at save time, find_unresolved_critical_refs at run + // time). + #[test] + fn agent_freetext_exposes_only_output_no_data_envelope() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("brainstorm", "Three ideas:\n- A\n- B\n- C"); + + // `.output` works. + let raw = ctx.render("{{steps.brainstorm.output}}").unwrap(); + assert!(raw.contains("Three ideas")); + // `.data` does NOT resolve → placeholder kept literal. + assert_eq!( + ctx.render("{{steps.brainstorm.data}}").unwrap(), + "{{steps.brainstorm.data}}", + ); + } + + // ── Gate ──────────────────────────────────────────────────────── + // Gate steps emit only the rendered `gate_message` as `output` — + // no envelope. Downstream steps that need a structured "decision" + // should read from a sibling Agent step, not from Gate. + #[test] + fn gate_exposes_only_output_no_envelope() { + let mut ctx = TemplateContext::new(); + let gate_msg = "Review the triage manifest:\n\nApprove / Reject?"; + ctx.set_step_output("review_triage", gate_msg); + + assert_eq!( + ctx.render("{{steps.review_triage.output}}").unwrap(), + gate_msg, + ); + // `.data` stays unresolved — by design. + assert_eq!( + ctx.render("{{steps.review_triage.data}}").unwrap(), + "{{steps.review_triage.data}}", + ); + } + + // ── BatchApiCall + BatchQuickPrompt ───────────────────────────── + fn sample_batch_output() -> String { + // 0.8.5 — batch fan-outs now emit the canonical envelope + // with the status name doubling as the SIGNAL value so + // `on_result.contains` rules can branch on PARTIAL / ERROR. + crate::workflows::step_output_format::format_step_output( + serde_json::json!({ + "batch_run_id": "br-1", + "total": 3, + "completed": 3, + "failed": 0, + "discussion_ids": ["d1", "d2", "d3"], + }), + "OK", + "Batch 3/3 completed", + None, + &["OK"], + ) + } + + #[test] + fn batch_exposes_counters_and_discussion_ids() { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("triage_batch", &sample_batch_output()); + + assert_eq!( + ctx.render("{{steps.triage_batch.status}}").unwrap(), + "OK", + ); + assert_eq!( + ctx.render("{{steps.triage_batch.data.completed}}").unwrap(), + "3", + ); + assert_eq!( + ctx.render("{{steps.triage_batch.data.failed}}").unwrap(), + "0", + ); + // Nested array index access. + assert_eq!( + ctx.render("{{steps.triage_batch.data.discussion_ids.0}}").unwrap(), + "d1", + ); + } + + // ── Chained source → consumer pairs ───────────────────────────── + // + // The matrix above pins each source in isolation. These tests + // pin canonical SOURCE → CONSUMER pairs in the order they're + // composed in real workflows — guards against a regression that + // breaks the "obvious" wiring (ApiCall→Agent, JsonData→Batch, …) + // even when individual envelope extractions still pass. + + #[test] + fn pair_jsondata_to_agent_data_passes_through() { + // JsonData fixture → Agent reads `{{steps.fetch.data.body}}`. + // This is the contract of the feasibility-autopilot preset + // in its original (JsonData) form. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fetch", &sample_json_data_output()); + let agent_prompt = "Triage this: {{steps.fetch.data.body}}"; + let rendered = ctx.render(agent_prompt).unwrap(); + assert_eq!(rendered, "Triage this: Refactor login button"); + } + + #[test] + fn pair_apicall_to_agent_full_data_passes_through() { + // ApiCall (Jira) → Agent reads `{{steps.fetch_issue.data}}` — + // the AutoCode EW-7247 path after the 0.8.5 prompt fix. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fetch_issue", &sample_apicall_jira_output()); + let agent_prompt = "Triage: {{steps.fetch_issue.data}}"; + let rendered = ctx.render(agent_prompt).unwrap(); + assert!(rendered.contains("EW-7247")); + assert!(rendered.contains("Africanews")); + } + + #[test] + fn pair_agent_to_exec_signal_only() { + // Agent emits the typed manifest; downstream Exec doesn't + // read structured data (Exec is a shell command) but its + // `command_template` may reference `{{steps.triage.summary}}` + // for logging / branching. + let mut ctx = TemplateContext::new(); + ctx.set_step_output( + "triage", + &sample_agent_structured_output( + serde_json::json!({ "files_touched": ["src/x.rs"] }), + "1 file", + ), + ); + let exec_log_template = "echo 'triage said: {{steps.triage.summary}}'"; + assert_eq!( + ctx.render(exec_log_template).unwrap(), + "echo 'triage said: 1 file'", + ); + } + + #[test] + fn pair_exec_to_agent_exit_code_branching() { + // Real-world: run_tests Exec → pr_draft Agent reads exit code + // to decide tone of PR description. Pin that exit_code is + // reachable as a string. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("run_tests", &sample_exec_output(0, "ok")); + let pr_template = + "Tests: {{steps.run_tests.status}} (exit {{steps.run_tests.data.exit_code}})"; + assert_eq!( + ctx.render(pr_template).unwrap(), + "Tests: OK (exit 0)", + ); + } + + #[test] + fn pair_apicall_to_notify_propagates_payload() { + // ApiCall (fetch tickets) → Notify (Slack-style webhook) uses + // `{{steps.fetch.data}}` as the JSON body. The data string is + // available verbatim for inline substitution. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fetch", &sample_apicall_jira_output()); + let notify_body = "{{steps.fetch.summary}} — see {{steps.fetch.data.key}}"; + assert_eq!( + ctx.render(notify_body).unwrap(), + "GET /rest/api/3/issue/EW-7247 → 1 item — see EW-7247", + ); + } + + #[test] + fn pair_gate_to_following_step_only_output_visible() { + // Gate's output is the gate_message verbatim. A step right + // after a Gate (e.g. an Agent that reads "what did the gate + // say?") can only consume `.output`, not `.data`. This + // failure mode is silent (placeholder stays literal) — the + // save-time `validate_step_references` is what catches the + // mistake in the wizard. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("review", "Approve / Reject?"); + assert_eq!( + ctx.render("{{steps.review.output}}").unwrap(), + "Approve / Reject?", + ); + assert_eq!( + ctx.render("{{steps.review.data}}").unwrap(), + "{{steps.review.data}}", + ); + } + + #[test] + fn pair_batch_to_agent_aggregate_handover() { + // BatchQuickPrompt / BatchApiCall → Agent summarisation step + // reads the per-discussion ids + completion counters. + let mut ctx = TemplateContext::new(); + ctx.set_step_output("fan_out", &sample_batch_output()); + let summary_prompt = + "Out of {{steps.fan_out.data.total}}, {{steps.fan_out.data.completed}} succeeded."; + assert_eq!( + ctx.render(summary_prompt).unwrap(), + "Out of 3, 3 succeeded.", + ); + } + + // ── Backwards-compat: extractor still reads pre-0.8.5 bare JSON ─ + // + // Runs persisted in DB before the canonical-envelope refactor + // hold the OLD shape (bare `{data,status,summary}` JSON + an + // optional trailing `[SIGNAL: ...]`). The extractor must keep + // parsing those so legacy run logs render correctly in the UI + // and old discussion threads still resolve `{{steps.X.data}}`. + // Loss of this fallback would be a silent data-loss regression + // on existing customer DBs. + #[test] + fn legacy_bare_json_envelope_still_extracts_correctly() { + let legacy = "{\"data\":{\"key\":\"EW-1\",\"body\":\"hi\"},\"status\":\"OK\",\"summary\":\"legacy run\"}\n[SIGNAL: OK]"; + let mut ctx = TemplateContext::new(); + ctx.set_step_output("legacy", legacy); + assert_eq!(ctx.render("{{steps.legacy.status}}").unwrap(), "OK"); + assert_eq!(ctx.render("{{steps.legacy.summary}}").unwrap(), "legacy run"); + assert_eq!(ctx.render("{{steps.legacy.data.key}}").unwrap(), "EW-1"); + assert_eq!(ctx.render("{{steps.legacy.data.body}}").unwrap(), "hi"); + } + + // ── Catch-all: every structured step type populates the four + // canonical keys (.output, .data, .summary, .status). Failure + // here means a step's output_format isn't strategy-1/2 + // parseable — the single most damaging regression class + // because it silently breaks every downstream `{{steps.X.…}}`. + #[test] + fn canonical_keys_present_for_every_envelope_producing_step_type() { + // Each tuple = (kind label, sample output string emitter). + let cases: Vec<(&str, String)> = vec![ + ("JsonData", sample_json_data_output()), + ("ApiCall", sample_apicall_jira_output()), + ("Notify", sample_notify_output()), + ("Exec(success)", sample_exec_output(0, "ok")), + ("Exec(failure)", sample_exec_output(1, "bad")), + ( + "Agent(Structured)", + sample_agent_structured_output( + serde_json::json!({ "x": 1 }), + "agent summary", + ), + ), + ("BatchApiCall/BatchQuickPrompt", sample_batch_output()), + ]; + + for (label, output) in cases { + let mut ctx = TemplateContext::new(); + ctx.set_step_output("src", &output); + + // All four canonical keys must resolve to non-placeholder. + for field in ["output", "data", "summary", "status"] { + let placeholder = format!("{{{{steps.src.{}}}}}", field); + let rendered = ctx.render(&placeholder).unwrap(); + assert_ne!( + rendered, placeholder, + "{label}: `{field}` did not resolve (envelope extraction broken)", + ); + assert!( + !rendered.is_empty(), + "{label}: `{field}` resolved to empty (envelope shape regressed)", + ); + } + } + } + } } diff --git a/desktop/package.json b/desktop/package.json index 786be7c6..f4e91e0d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "kronn-desktop", - "version": "0.8.4", + "version": "0.8.5", "private": true, "scripts": { "dev": "tauri dev", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 45ee27e0..7936f08f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "kronn" -version = "0.8.4" +version = "0.8.5" dependencies = [ "aes-gcm", "anyhow", @@ -2342,7 +2342,7 @@ dependencies = [ [[package]] name = "kronn-desktop" -version = "0.8.4" +version = "0.8.5" dependencies = [ "anyhow", "axum", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 247b79b7..1b646e56 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kronn-desktop" -version = "0.8.4" +version = "0.8.5" edition = "2021" description = "Kronn Desktop — Self-hosted AI coding agent control plane" license = "AGPL-3.0-only" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0ca04555..012c5a01 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "Kronn", - "version": "0.8.4", + "version": "0.8.5", "identifier": "com.kronn.desktop", "build": { "frontendDist": "../../frontend/dist", diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fcc5bcea..13a69e4c 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -249,16 +249,42 @@ Projects display 3 badges next to the title: `[FileCode] Project docs`, `[Cpu] A `GET /api/projects/:id/workflow-suggestions` — matches installed MCPs against a hardcoded catalogue of 10 workflow templates. Returns suggestions with multi-step prompts, pre-filled triggers, and audience tags (dev/pm/ops). Suggestions use structured inter-step contracts for reliable data passing between collection and synthesis steps. -### Structured inter-step contract +### Structured inter-step contract (canonical Kronn envelope, 0.8.5+) -Workflow steps can declare `output_format: Structured` (default: `FreeText`). When structured: -1. Engine auto-injects `---STEP_OUTPUT---` envelope instructions into the prompt -2. After execution, extracts JSON envelope `{"data": ..., "status": "OK|NO_RESULTS|ERROR", "summary": "..."}` -3. If extraction fails, sends a repair prompt (truncated to 2000 chars) for reformatting -4. Downstream steps access `{{previous_step.data}}`, `{{previous_step.summary}}`, `{{previous_step.status}}` -5. `status: "NO_RESULTS"` is detected by the condition system (replaces `[SIGNAL: NO_RESULTS]` for structured steps) +**Every envelope-producing step type emits the same byte-for-byte shape via `workflows/step_output_format.rs::format_step_output`:** -A third variant, `StepOutputFormat::TypedSchema { schema }` (0.7.0 Phase 2, shipped in 0.6.0), runs the same envelope extraction but additionally validates `data` against a JSON Schema. Validation failures fall back to the structured-repair prompt with the schema diagnostic appended. See `backend/src/models/mod.rs::StepOutputFormat` and `backend/src/workflows/steps.rs` (extraction + validation hook). +``` +[optional human-readable prefix line(s)] +---STEP_OUTPUT--- +{"data": , "status": "OK|NO_RESULTS|ERROR|PARTIAL|PENDING|…", "summary": ""} +---END_STEP_OUTPUT--- +[SIGNAL: ] +[SIGNAL: ] +``` + +| Step type | Emits canonical envelope | Primary signal | +|---|---|---| +| `ApiCall`, `BatchApiCall` | yes | `OK` / `NO_RESULTS` / `ERROR` (+ `http_` on HTTP errors) | +| `Exec` | yes | `OK` / `ERROR` + `exit_` | +| `JsonData` | yes | `OK` | +| `Notify` | yes (0.8.5+) | `OK` / `ERROR` (0.8.5+, pre-fix none) | +| `BatchQuickPrompt` | yes (0.8.5+) | `OK` / `PARTIAL` / `ERROR` / `PENDING` (0.8.5+, pre-fix none) | +| `Agent` (Structured / TypedSchema) | yes — prompt template emits markers | whatever the prompt instructs | +| `Agent` (FreeText) | **no** — raw text only, consumers read `.output` only | whatever the agent prints | +| `Gate` | **no** — output is the rendered `gate_message`, has no semantic data | none — Gate is a pause, branch via `request_changes_target` | + +For Agent steps with `output_format: Structured` or `TypedSchema`, the engine auto-injects the envelope instructions into the prompt, and `extract_step_envelope` parses the result via marker-delimited strategy-1 (preferred) or last-bare-JSON-with-`data`+`status` strategy-2 (legacy back-compat for pre-0.8.5 run records). For all other step types, the runner writes the canonical envelope directly. `TypedSchema { schema, on_invalid: Continue|Fail }` adds JSON-Schema validation on top of the same envelope — failures fall back to a repair prompt with the schema diagnostic, then optionally fail the step. + +Downstream consumers read the same access patterns regardless of producer: +- `{{steps.X.data}}` — JSON payload (compact for objects/arrays, string for scalars) +- `{{steps.X.data.}}` — nested traversal (dot-separated, numeric segments index arrays). Missing fields leave the placeholder literal AND `find_unresolved_critical_refs` fails the consuming step with an actionable error. +- `{{steps.X.summary}}` / `{{steps.X.status}}` — the one-line summary / status string +- `{{steps.X.data_json}}` — always-serialized JSON, useful for piping into a downstream HTTP body +- `{{steps.X.output}}` — raw output (every step type, always available — fallback for Gate / FreeText consumers) + +Cross-step transmission is pinned by the comprehensive matrix in `backend/src/workflows/template.rs::cross_step_transmission` (17 tests) — any step type that regresses its emitted shape fails one localised test instead of silently breaking every consumer. + +See `backend/src/workflows/step_output_format.rs` (the single emitter), `backend/src/workflows/template.rs` (the extractor + ctx), and `backend/src/models/mod.rs::StepOutputFormat` for the Agent-side variants. ### Workflow Engine 0.7.x features (shipped in 0.6.0) diff --git a/frontend/e2e/pages/WorkflowWizardPage.ts b/frontend/e2e/pages/WorkflowWizardPage.ts index 1407b2f2..b94c8d77 100644 --- a/frontend/e2e/pages/WorkflowWizardPage.ts +++ b/frontend/e2e/pages/WorkflowWizardPage.ts @@ -2,38 +2,83 @@ import type { Page, Locator } from '@playwright/test'; /** * Workflow creation wizard. Opens after clicking "Nouveau workflow" on the - * Workflows page. Modes : `simple` / `advanced` (toggle at top). Steps page - * (advanced mode) shows the preset cards at top + the step editor below. + * Workflows page. + * + * **0.8.5 layout change.** Pre-0.8.5 the wizard had three separate + * "start from something" surfaces: STARTER_TEMPLATES buttons at the + * top of step 0, project-suggestions toggle at the top of step 0, + * and v0.7 preset cards buried in advanced step 2. The page-object + * exposed `gotoStepsPage()` to walk to step 2 where the preset cards + * lived. Since 0.8.5 the three sources are unified in a single + * `WorkflowQuickStartPicker` on step 0 (Infos), right after the + * name + project inputs. The picker is **collapsed by default** and + * **disabled until the workflow name is filled**. Tests should + * therefore use `openQuickStartPicker(name)` (which fills the name + * + clicks the toggle) and `applyQuickStart(name, titleRe)` (which + * also clicks the Apply button on the matching row + lets the wizard + * jump to advanced step 2). */ export class WorkflowWizardPage { constructor(private readonly page: Page) {} - // ─── Mode toggle (advanced gives access to presets) ───────────────── + // ─── Mode toggle (advanced is the default in 0.6+) ────────────────── get advancedModeButton(): Locator { return this.page.getByRole('button', { name: /Avancé|Advanced/i }).first(); } - // ─── Preset cards ─────────────────────────────────────────────────── - /** Preset card by its title (i18n-aware regex). */ - preset(titleRe: RegExp): Locator { - // Presets render their title inside `.wf-preset-card` — but to keep the - // selector resilient to class renames, anchor on the preset id which - // is in the i18n key. Easiest stable handle: clickable element whose - // accessible name matches the title. - return this.page.getByRole('button', { name: titleRe }); + // ─── QuickStart picker (0.8.5+, unified entry point on step 0) ────── + /** + * The collapsed toggle chip shown when the picker is closed. Matches + * the i18n key `wiz.quickstart.toggle` (interpolated with the entry + * count) across FR / EN / ES. + */ + get quickStartToggle(): Locator { + return this.page.getByRole('button', { + name: /modèles disponibles|ready-made templates available|plantillas disponibles/i, + }); + } + + /** Search input inside the expanded picker panel. */ + get quickStartSearchInput(): Locator { + return this.page.getByPlaceholder(/Rechercher un modèle|Search templates|Buscar plantilla/i); + } + + /** + * Pick the `
  • ` whose title `` + * matches `titleRe`. Used as the locator surface for `toBeVisible()` + * + `toContainText()` assertions across the wizard-presets specs. + */ + quickStartRow(titleRe: RegExp): Locator { + return this.page.locator('li.wf-quickstart-row').filter({ + has: this.page.locator('span.wf-quickstart-row-title', { hasText: titleRe }), + }); + } + + /** + * The "Use this template" button inside the matching row. Returns the + * button, not the row, so `.click()` actually triggers `onApply` on + * the picker. + */ + quickStartApplyButton(titleRe: RegExp): Locator { + return this.quickStartRow(titleRe).getByRole('button', { + name: /Utiliser ce modèle|Use this template|Usar esta plantilla/i, + }); } - get presetAutoDev(): Locator { return this.preset(/Auto-Dev avec tests|Auto-Dev with tests/i); } - // 0.8.3 — anchor on the 🎫 emoji prefix (unique to `ticket-to-pr`) - // because the broader `/Ticket Autopilot/i` substring also matches - // the new `🎯 Big-ticket AutoPilot — feasibility-gated` preset and - // strict-mode fails with "resolved to 2 elements". The emoji is - // part of the preset's `icon` field (frozen contract, see - // `workflow-templates/v07-presets.ts:489`), so this locator is as - // stable as the title regex was. - get presetTicketToPr(): Locator { return this.preset(/🎫\s*Ticket Autopilot/i); } - get presetFeasibilityAutopilot(): Locator { return this.preset(/🎯\s*Big-ticket AutoPilot/i); } - get presetDailyHostAudit(): Locator { return this.preset(/Audit quotidien|Daily audit/i); } + // ─── Preset-card backward-compat (0.8.5+) ─────────────────────────── + // Return the picker rows that correspond to the 4 frequently-referenced + // v0.7 presets. Tests use these for visibility assertions; for clicks, + // use `quickStartApplyButton(titleRe)` since the row itself is a `
  • ` + // (the apply button is a sibling of the title). + get presetAutoDev(): Locator { return this.quickStartRow(/Auto-Dev avec tests|Auto-Dev with tests/i); } + // 0.8.3 emoji anchor (preserved across the 0.8.5 picker refactor) — the + // generic `/Ticket Autopilot/i` substring also matches `🎯 Big-ticket + // AutoPilot — feasibility-gated`, so we anchor on the unique `🎫` + // prefix from the preset's `icon` field. See + // `frontend/src/lib/workflow-templates/v07-presets.ts::TICKET_TO_PR.icon`. + get presetTicketToPr(): Locator { return this.quickStartRow(/🎫\s*Ticket Autopilot/i); } + get presetFeasibilityAutopilot(): Locator { return this.quickStartRow(/🎯\s*Big-ticket AutoPilot/i); } + get presetDailyHostAudit(): Locator { return this.quickStartRow(/Audit quotidien|Daily audit/i); } // ─── Step type buttons (in-step editor) ───────────────────────────── /** Step type button inside a step card. Use `data-type` for stable @@ -65,19 +110,41 @@ export class WorkflowWizardPage { return this.page.getByPlaceholder(/Auto-fix|Nom du workflow/i).first(); } - /** Switch to advanced mode (which exposes the preset cards on the - * Steps page). NB: advanced is the default in 0.6+. */ + /** Switch to advanced mode (default in 0.6+, no-op when already there). */ async selectAdvancedMode() { if (await this.advancedModeButton.isVisible().catch(() => false)) { await this.advancedModeButton.click(); } } - /** Walk through the advanced wizard from Infos → Steps : - * 1. fill the name input (required to enable Next on Infos) - * 2. click Next (Infos → Trigger) - * 3. click Next (Trigger → Steps) - * After this, preset cards are visible. + /** + * Fill the name + click the QuickStart toggle to expand the panel. + * Mandatory before any preset assertion or click since the picker is + * disabled when the name is empty (gates the "click template before + * naming the workflow" UX pitfall flagged 2026-05-17). + */ + async openQuickStartPicker(name: string) { + await this.selectAdvancedMode(); + await this.nameInput.fill(name); + await this.quickStartToggle.click(); + } + + /** + * Apply a preset / starter / suggestion by its visible title regex. + * The wizard's `applyQuickStart` handler auto-jumps to advanced step 2 + * (Steps), matching the pre-0.8.5 behaviour of clicking a preset card. + */ + async applyQuickStart(name: string, titleRe: RegExp) { + await this.openQuickStartPicker(name); + await this.quickStartApplyButton(titleRe).click(); + } + + /** + * @deprecated 0.8.5 — preset cards moved from step 2 to step 0 (Infos). + * Use `openQuickStartPicker(name)` to land on the picker, or + * `applyQuickStart(name, titleRe)` to one-shot apply + jump to Steps. + * Kept for any spec that wants to walk through the wizard pages + * manually without touching the picker. */ async gotoStepsPage(name: string) { await this.selectAdvancedMode(); diff --git a/frontend/e2e/specs/wizard-create-button-validation.spec.ts b/frontend/e2e/specs/wizard-create-button-validation.spec.ts index 0b58f31b..9c027b46 100644 --- a/frontend/e2e/specs/wizard-create-button-validation.spec.ts +++ b/frontend/e2e/specs/wizard-create-button-validation.spec.ts @@ -26,12 +26,14 @@ test.describe('Wizard — Create button validation', () => { await dashboard.goto(); await dashboard.clickWorkflows(); await workflows.openNewWorkflowWizard(); - await wizard.gotoStepsPage('e2e-create-validation'); - // Apply the Ticket Autopilot preset → 9 steps populated, first is - // JsonData with a `json_data_payload`. The predicate would disable - // Create if it (wrongly) fell back to the prompt_template branch. - await wizard.presetTicketToPr.click(); + // 0.8.5 — applies the Ticket Autopilot preset via the unified + // QuickStartPicker on step 0 (Infos). The wizard auto-jumps to + // advanced step 2 (Steps) after applying, matching the pre-0.8.5 + // flow. The preset's first step is JsonData with a + // `json_data_payload` — the predicate would wrongly disable Create + // if it fell back to the `prompt_template` branch. + await wizard.applyQuickStart('e2e-create-validation', /🎫\s*Ticket Autopilot/i); // Walk to the last step (Résumé) where the Create button lives : // Steps → Config → Résumé diff --git a/frontend/e2e/specs/wizard-presets.spec.ts b/frontend/e2e/specs/wizard-presets.spec.ts index 83a4134a..cfb33eff 100644 --- a/frontend/e2e/specs/wizard-presets.spec.ts +++ b/frontend/e2e/specs/wizard-presets.spec.ts @@ -2,11 +2,11 @@ * Workflow wizard preset cards — rendering + click flow. * * Regression guards : - * - All 6 v07 presets render in the wizard's "Démarrer depuis un pattern" - * section : AUTO_DEV, PR_GATE, DEPLOY_ROLLBACK, FEATURE_PLANNER, + * - All 6 v07 presets render in the unified QuickStart picker (0.8.5+) : + * AUTO_DEV, PR_GATE, DEPLOY_ROLLBACK, FEATURE_PLANNER, * DAILY_HOST_AUDIT, TICKET_TO_PR (the last is the 0.7+ Sprint 1 add). - * - The advanced mode toggle gates the preset cards (simple mode has - * no presets — the simple wizard skips straight to a free-form prompt). + * - The picker is gated by the workflow name (disabled when empty) so + * `openQuickStartPicker(name)` is the canonical entry path. */ import { test, expect } from '../fixtures/kronn-fixture'; @@ -15,7 +15,7 @@ import { WorkflowsPage } from '../pages/WorkflowsPage'; import { WorkflowWizardPage } from '../pages/WorkflowWizardPage'; test.describe('Workflow wizard — preset cards', () => { - test('all 6 v07 presets are visible in advanced mode', async ({ page }) => { + test('all 6 v07 presets are visible in the QuickStart picker', async ({ page }) => { const dashboard = new DashboardPage(page); const workflows = new WorkflowsPage(page); const wizard = new WorkflowWizardPage(page); @@ -23,23 +23,28 @@ test.describe('Workflow wizard — preset cards', () => { await dashboard.goto(); await dashboard.clickWorkflows(); await workflows.openNewWorkflowWizard(); - await wizard.gotoStepsPage('e2e-test'); + // 0.8.5 — picker lives on step 0 (Infos) now. Open it after filling + // the name (the toggle is disabled until then). + await wizard.openQuickStartPicker('e2e-test'); - // Each preset's title should be visible. Regex tolerate FR/EN. + // Each preset's title should be visible in the picker. Regex tolerate + // FR/EN. The page object's preset getters return the `
  • ` row whose + // title `` matches the regex. await expect(wizard.presetAutoDev).toBeVisible({ timeout: 5_000 }); await expect(wizard.presetTicketToPr).toBeVisible(); await expect(wizard.presetDailyHostAudit).toBeVisible(); - // The other 3 presets — match by their canonical FR title to avoid - // over-coupling to the WorkflowWizardPage helpers. - await expect(page.getByRole('button', { name: /Pipeline PR avec Gate humain|Pipeline PR with human Gate/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Déploiement avec rollback|Deployment with rollback/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Feature \/ Epic Planner/i })).toBeVisible(); + // The other 3 presets — match by their canonical FR title via + // `page.getByText` (the title sits in a ``, not a button). + await expect(page.getByText(/Pipeline PR avec Gate humain|Pipeline PR with human Gate/i)).toBeVisible(); + await expect(page.getByText(/Déploiement avec rollback|Deployment with rollback/i)).toBeVisible(); + await expect(page.getByText(/Feature \/ Epic Planner/i)).toBeVisible(); }); test('Ticket Autopilot preset is the latest add (0.7+)', async ({ page }) => { // Smoke check that the 0.7+ Sprint 1 preset shipped. If this regresses - // we know v07-presets.ts lost the TICKET_TO_PR entry. + // we know v07-presets.ts lost the TICKET_TO_PR entry OR the picker + // adapter dropped the preset source. const dashboard = new DashboardPage(page); const workflows = new WorkflowsPage(page); const wizard = new WorkflowWizardPage(page); @@ -47,12 +52,11 @@ test.describe('Workflow wizard — preset cards', () => { await dashboard.goto(); await dashboard.clickWorkflows(); await workflows.openNewWorkflowWizard(); - await wizard.gotoStepsPage('e2e-test'); + await wizard.openQuickStartPicker('e2e-test'); const ticketToPr = wizard.presetTicketToPr; await expect(ticketToPr).toBeVisible(); - // The card mentions "Ticket Autopilot" in its accessible name (title) - // — the description ("Pipeline complet…") sits inside the same card. + // The row contains "🎫 Ticket Autopilot" in its title. await expect(ticketToPr).toContainText(/Ticket Autopilot/i); }); }); diff --git a/frontend/e2e/specs/wizard-save-error.spec.ts b/frontend/e2e/specs/wizard-save-error.spec.ts index aa490865..7b90c32b 100644 --- a/frontend/e2e/specs/wizard-save-error.spec.ts +++ b/frontend/e2e/specs/wizard-save-error.spec.ts @@ -45,12 +45,13 @@ test.describe('Wizard — save error banner', () => { await dashboard.goto(); await dashboard.clickWorkflows(); await workflows.openNewWorkflowWizard(); - await wizard.gotoStepsPage('e2e-save-error'); - // Apply Ticket Autopilot to have a complete valid workflow shape. - // The 400 we inject targets the SAVE call, not validation, so we - // don't care about pre-save predicate logic here. - await wizard.presetTicketToPr.click(); + // 0.8.5 — apply Ticket Autopilot via the unified QuickStart picker on + // step 0. Gives us a complete valid workflow shape; the 400 we inject + // targets the SAVE call, not validation, so we don't care about + // pre-save predicate logic here. The wizard auto-jumps to step 2 + // (Steps) after applying. + await wizard.applyQuickStart('e2e-save-error', /🎫\s*Ticket Autopilot/i); // Steps → Config → Résumé. await wizard.nextButton.click(); diff --git a/frontend/package.json b/frontend/package.json index 7ad4fafd..ba4b74e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kronn-frontend", - "version": "0.8.4", + "version": "0.8.5", "private": true, "type": "module", "engines": { diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 5ae550dc..74537d9b 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -207,6 +207,30 @@ export function ChatHeader({ 🔧 {discussion.introspection_call_count} )} + {/* 0.8.5 — short disc-id pill. Surfaces the id so a user + reading an agent summary like "Disc 3 — 04a9c927" can + click → copy full UUID + paste it anywhere (next disc, + linked-issue field, Slack message…). Sidebar search also + matches id prefix in 0.8.5, so this works as a round-trip + "agent quotes id → user finds disc in sidebar". Discreet + ghost-text styling so it doesn't compete with the title. */} + {!isValidationDisc(discussion.title) && !isBootstrapDisc(discussion.title) && !isBriefingDisc(discussion.title) && ( + ); + } + + return ( +
    +
    + + {t('wiz.quickstart.title')} + {t('wiz.quickstart.hint')} + +
    + +
    +
    +
    +
    + {t('wiz.quickstart.filterComplexity')} + {ALL_COMPLEXITIES.map(c => { + const active = complexityFilter.includes(c); + return ( + + ); + })} +
    +
    + {t('wiz.quickstart.filterSource')} + {ALL_SOURCES.map(s => { + const active = sourceFilter.includes(s); + return ( + + ); + })} +
    +
    + +
      + {filtered.map(entry => ( + + ))} + {filtered.length === 0 && ( +
    • {t('wiz.quickstart.empty')}
    • + )} +
    + + {loading && ( +
    + {t('wiz.quickstart.loading')} +
    + )} +
    + ); +} + +interface QuickStartRowProps { + entry: UnifiedQuickStart; + onApply: (entry: UnifiedQuickStart) => void; + t: WorkflowQuickStartPickerProps['t']; +} + +function QuickStartRow({ entry, onApply, t }: QuickStartRowProps) { + const stepNames = quickStartStepsPreview(entry, 4); + return ( +
  • +
    + {entry.title} +
    + + {t(`wiz.quickstart.complexity.${entry.complexity}`)} + + + {t(`wiz.quickstart.source.${entry.source}`)} + + {entry.audience && ( + {entry.audience} + )} +
    +
    + +

    {entry.description}

    + + {entry.reason && ( +

    {entry.reason}

    + )} + +
    + + {t('wiz.quickstart.stepsCount', entry.stepsCount)} + + {entry.badges.map(b => ( + {b} + ))} + {stepNames.length > 0 && ( + + )} +
    + + {!entry.applicable && entry.notApplicableReason && ( +

    + {t('wiz.quickstart.notApplicable', entry.notApplicableReason)} +

    + )} + +
    + +
    +
  • + ); +} diff --git a/frontend/src/components/workflows/WorkflowWizard.tsx b/frontend/src/components/workflows/WorkflowWizard.tsx index ecc4142f..a3374a4d 100644 --- a/frontend/src/components/workflows/WorkflowWizard.tsx +++ b/frontend/src/components/workflows/WorkflowWizard.tsx @@ -4,6 +4,8 @@ import { workflows as workflowsApi, skills as skillsApi, profiles as profilesApi import { ApiCallStepCard, type ApiPluginOption } from './ApiCallStepCard'; import { STARTER_TEMPLATES, cloneTemplateSteps } from '../../lib/workflow-templates/chartbeat-top5'; import { buildV07Presets } from '../../lib/workflow-templates/v07-presets'; +import { WorkflowQuickStartPicker } from './WorkflowQuickStartPicker'; +import { buildQuickStartCatalogue, type UnifiedQuickStart } from '../../lib/workflow-quick-start'; import { parseRepoUrl, buildOldestIssueRequest, inferTrackerSlugFromRepoUrl } from '../../lib/constants'; import { AGENT_COLORS, AGENT_LABELS, ALL_AGENT_TYPES, isAgentRestricted } from '../../lib/constants'; import type { @@ -19,7 +21,7 @@ import type { AgentsConfig } from '../../types/generated'; import { Plus, Loader2, Check, X, ChevronRight, ChevronDown, ChevronUp, Clock, GitBranch, Zap, HelpCircle, Settings, Shield, - AlertTriangle, UserCircle, FileText, Sparkles, Layers, Send, + AlertTriangle, UserCircle, FileText, Layers, Send, Info, Hand, RotateCcw, Terminal, Bot, Plug, Braces, } from 'lucide-react'; import { scanUndeclaredVars } from '../../lib/scanUndeclaredVars'; @@ -335,7 +337,6 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta // pipeline is visible. setWizardMode('advanced'); setWizardStep(2); - setShowSuggestions(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialPresetId, initialProjectId, pluginsLoaded]); // 0.7.0 Phase 5b — ref so the Exec form's "Configure now" button can @@ -409,9 +410,9 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta }); }; - // Workflow suggestions from MCP introspection + // Workflow suggestions from MCP introspection (fed into the unified + // QuickStart picker — no longer surfaced via its own toggle). const [suggestions, setSuggestions] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); const [suggestionsLoading, setSuggestionsLoading] = useState(false); useEffect(() => { @@ -449,16 +450,10 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta if (!projectId) { setSuggestions([]); return; } setSuggestionsLoading(true); workflowsApi.suggestions(projectId) - .then(s => { - setSuggestions(s); - // Auto-show suggestions UNLESS we're applying a deep-linked preset - // — in that case the user came here for a specific preset, not for - // orthogonal MCP-aware suggestions. Showing both clutters the view. - if (s.length > 0 && !isEdit && !initialPresetId) setShowSuggestions(true); - }) + .then(setSuggestions) .catch(() => setSuggestions([])) .finally(() => setSuggestionsLoading(false)); - }, [projectId, isEdit, initialPresetId]); + }, [projectId]); const applySuggestion = (s: WorkflowSuggestion) => { setName(s.title); @@ -472,7 +467,6 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta setCronWeekdays(parsed.weekdays); setCronRaw(parsed.raw ?? ''); } - setShowSuggestions(false); // Multi-step or advanced suggestions → force advanced mode if (s.steps.length > 1 || s.complexity === 'advanced') { setWizardMode('advanced'); @@ -493,11 +487,40 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta setName(template.title_fr); setSteps(steps); setTriggerType('Manual'); - setShowSuggestions(false); setWizardMode('advanced'); setWizardStep(2); // Jump straight to Steps so the user sees the chain. }; + /** 0.8.5 — unified entry point for the QuickStart picker. + * Branches on the payload kind to delegate to the existing apply + * functions / inline setters — keeps the three native shapes intact + * while presenting one consistent UI. */ + const applyQuickStart = (entry: UnifiedQuickStart) => { + switch (entry.payload.kind) { + case 'starter': + applyStarterTemplate(entry.payload.template.id); + break; + case 'project-suggestion': + applySuggestion(entry.payload.suggestion); + break; + case 'preset': { + // Mirrors the previous inline preset-card click handler: + // steps + on-failure + exec-allowlist + variables. Does NOT + // set the name — a preset is project-agnostic, the user + // names it. Jump to advanced step 2 so the user sees the + // applied chain immediately (same behaviour as starters). + const p = entry.payload.preset; + setSteps(p.steps); + if (p.onFailure) setOnFailureSteps(p.onFailure); + if (p.execAllowlist) setExecAllowlist(p.execAllowlist); + if (p.variables) setWfVariables(p.variables); + setWizardMode('advanced'); + setWizardStep(2); + break; + } + } + }; + /** Build a fresh blank step. Centralised so `addStep` (append) and * `insertStep` (insert-at-position) share the same defaults. */ const blankStep = (existingCount: number): WorkflowStep => ({ @@ -766,76 +789,6 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta )} - {/* Starter templates — désagentification aha moment. Only shown on - fresh creation (not edit) and only if at least one template's - primary plugin is configured on the project (otherwise the clone - would leave `api_config_id: null` and the step can't run). */} - {!isEdit && STARTER_TEMPLATES.some(t => availableApiPlugins.some(p => p.server.id === t.primary_plugin_slug)) && ( -
    - {STARTER_TEMPLATES.filter(t => availableApiPlugins.some(p => p.server.id === t.primary_plugin_slug)).map(tpl => ( - - ))} -
    - )} - - {/* Suggestions toggle */} - {suggestions.length > 0 && !showSuggestions && ( - - )} - - {/* Suggestions panel */} - {showSuggestions && suggestions.length > 0 && ( -
    -
    - - {t('wiz.suggestionsTitle')} - -
    -
    - {suggestions.map(s => ( -
    -
    - {s.title} -
    - {s.complexity === 'advanced' && {t('wiz.modeAdvanced')}} - {s.audience} -
    -
    -

    {s.description}

    -

    {s.reason}

    -
    - {s.required_mcps.map(m => {m})} - {s.steps.length} step{s.steps.length > 1 ? 's' : ''} -
    - -
    - ))} -
    - {suggestionsLoading &&
    } -
    - )} - {/* Progress bar */}
    {WIZARD_STEPS.map((label, i) => ( @@ -887,6 +840,36 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta ))} + + {/* 0.8.5 — unified QuickStart picker. Replaces three formerly + separate UI sections (STARTER_TEMPLATES buttons, project + suggestions toggle/panel, v0.7 preset bandeau). Placed + AFTER name+project so the user reads the field labels + first (else clicking a template before naming the + workflow bounced the wizard back to step 0 — confusing). + Disabled while `name` is empty, with an explanatory + tooltip; ungreys as soon as the user has typed + something. */} +
    + +
    )} @@ -1251,48 +1234,12 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta {/* Step 2 (advanced): Steps (with advanced per-step config) */} {!isSimple && wizardStep === 2 && (
    - {/* B4 (0.7.0 UX pass) — Bandeau "Démarrer depuis un pattern". - Visible uniquement à la création (pas en édition) ET tant - que la config est restée vierge (1 step nommé "main" avec - prompt vide). Dès que l'user customise, le bandeau s'efface - tout seul — pas besoin d'un bouton dismiss explicite. - Click sur une carte = applique steps + on_failure + - exec_allowlist du préset. Pédagogique : le user voit - comment c'est construit (pas de magie cachée). */} - {!isEdit && steps.length === 1 && steps[0].name === 'main' && steps[0].prompt_template === '' && ( -
    -
    - - {t('wiz.presetsTitle')} - {t('wiz.presetsHint')} -
    -
    - {buildV07Presets(t).map(preset => ( - - ))} -
    -

    {t('wiz.presetsBlankHint')}

    -
    - )} + {/* 0.8.5 — the previous "Démarrer depuis un pattern" bandeau + that lived here was unified with the project suggestions + and starter templates into a single QuickStart picker + shown at the TOP of step 0. Discovery in three different + places (and one of them buried in advanced > step 2) was + the worst pain point of the wizard. */} {/* Worktree isolation hint — visible whenever a project is bound. Each WorkflowRun creates its own git worktree at diff --git a/frontend/src/components/workflows/__tests__/WorkflowQuickStartPicker.test.tsx b/frontend/src/components/workflows/__tests__/WorkflowQuickStartPicker.test.tsx new file mode 100644 index 00000000..87ce156b --- /dev/null +++ b/frontend/src/components/workflows/__tests__/WorkflowQuickStartPicker.test.tsx @@ -0,0 +1,257 @@ +// 0.8.5 — picker UI tests. Verify the collapsed → expanded flow, the +// filter chips, the search input, and that clicking "Use this template" +// calls onApply with the original entry (so the wizard's apply* router +// gets the verbatim payload). + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WorkflowQuickStartPicker } from '../WorkflowQuickStartPicker'; +import type { UnifiedQuickStart } from '../../../lib/workflow-quick-start'; +import type { WorkflowStep } from '../../../types/generated'; + +function tFr(key: string, ...args: (string | number)[]): string { + // Minimal stub that mirrors the i18n function shape. Returns the key + // so tests can assert on it without depending on the real string + // table — the picker only cares that text is rendered. + if (args.length === 0) return key; + return `${key}:${args.join(',')}`; +} + +function makeStep(name: string): WorkflowStep { + return { + name, + agent: 'ClaudeCode', + prompt_template: '', + mode: { type: 'Normal' }, + } as WorkflowStep; +} + +function entry(overrides: Partial = {}): UnifiedQuickStart { + return { + id: 'preset:auto-dev', + source: 'preset', + title: 'Auto-dev (Agent + Exec)', + description: 'Lance un agent puis exécute les commandes shell.', + complexity: 'simple', + stepsCount: 2, + badges: ['Agent', 'Exec'], + applicable: true, + payload: { kind: 'preset', preset: { id: 'auto-dev', steps: [makeStep('main')] } as never }, + ...overrides, + }; +} + +describe('WorkflowQuickStartPicker — visibility', () => { + it('renders nothing when isEdit is true', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when the catalogue is empty', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the collapsed toggle by default', () => { + render( + , + ); + const toggle = screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i }); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle.textContent).toContain('2'); // count substituted into toggle label + }); +}); + +describe('WorkflowQuickStartPicker — expanded state', () => { + function setup() { + const onApply = vi.fn(); + render( + , + ); + // Expand the panel. + fireEvent.click(screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i })); + return { onApply }; + } + + it('shows all three rows after expanding', () => { + setup(); + expect(screen.getByText('Auto-dev')).toBeInTheDocument(); + expect(screen.getByText('Chartbeat Top 5')).toBeInTheDocument(); + expect(screen.getByText('AutoPilot ticket')).toBeInTheDocument(); + }); + + it('renders the search input + filter chips', () => { + setup(); + expect(screen.getByPlaceholderText(/wiz\.quickstart\.searchPlaceholder/)).toBeInTheDocument(); + // 3 complexity chips + 3 source chips + expect(screen.getAllByText(/wiz\.quickstart\.complexity\.(simple|intermediate|advanced)/)) + .toHaveLength(6); // 3 chips + 3 badges (one per row) + expect(screen.getAllByText(/wiz\.quickstart\.source\.(starter|preset|project-suggestion)/)) + .toHaveLength(6); // 3 chips + 3 badges + }); + + it('disables the Apply button on a non-applicable entry + shows reason', () => { + setup(); + expect(screen.getByText(/requires plugin: mcp-chartbeat/)).toBeInTheDocument(); + const buttons = screen.getAllByRole('button', { name: /wiz\.quickstart\.apply/ }); + // 3 entries, 3 apply buttons. The one tied to Chartbeat is disabled. + const disabled = buttons.filter(b => (b as HTMLButtonElement).disabled); + expect(disabled).toHaveLength(1); + }); + + it('calls onApply with the verbatim entry when "Use this template" is clicked', () => { + const { onApply } = setup(); + const buttons = screen.getAllByRole('button', { name: /wiz\.quickstart\.apply/ }); + // First entry = Auto-dev (preset, simple, applicable) + fireEvent.click(buttons[0]); + expect(onApply).toHaveBeenCalledTimes(1); + expect(onApply.mock.calls[0][0].id).toBe('preset:auto-dev'); + }); + + it('filters by free-text search', () => { + setup(); + const input = screen.getByPlaceholderText(/wiz\.quickstart\.searchPlaceholder/); + fireEvent.change(input, { target: { value: 'chartbeat' } }); + expect(screen.queryByText('Auto-dev')).not.toBeInTheDocument(); + expect(screen.getByText('Chartbeat Top 5')).toBeInTheDocument(); + expect(screen.queryByText('AutoPilot ticket')).not.toBeInTheDocument(); + }); + + it('shows the empty state when filters match nothing', () => { + setup(); + const input = screen.getByPlaceholderText(/wiz\.quickstart\.searchPlaceholder/); + fireEvent.change(input, { target: { value: 'zzzzzz' } }); + expect(screen.getByText(/wiz\.quickstart\.empty/)).toBeInTheDocument(); + }); + + it('filters by complexity chip', () => { + setup(); + // Find the chips by their text content (Translator returns the i18n key). + const advancedChip = screen.getAllByText('wiz.quickstart.complexity.advanced') + .map(el => el.closest('button')) + .find(b => b !== null) as HTMLButtonElement; + fireEvent.click(advancedChip); + expect(screen.queryByText('Auto-dev')).not.toBeInTheDocument(); + expect(screen.queryByText('Chartbeat Top 5')).not.toBeInTheDocument(); + expect(screen.getByText('AutoPilot ticket')).toBeInTheDocument(); + }); + + it('renders the reason blurb for suggestions that have one', () => { + setup(); + expect(screen.getByText('Le projet a un tracker GitHub.')).toBeInTheDocument(); + }); +}); + +// 0.8.5 dogfooding follow-up — gate the picker until the wizard's +// prerequisites are met (currently: a non-empty workflow name). Pre-fix +// the user could click a template before naming the workflow and the +// validator bounced them back to step 0 — confusing. +describe('WorkflowQuickStartPicker — disabled gate', () => { + it('renders a disabled toggle with the supplied tooltip when `disabled` is true', () => { + render( + , + ); + const toggle = screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i }); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAttribute('title', 'Saisissez un nom de workflow avant de sélectionner un modèle.'); + expect(toggle).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not call onApply when the disabled toggle is clicked', () => { + const onApply = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i })); + expect(onApply).not.toHaveBeenCalled(); + // Panel stays collapsed — no search input rendered. + expect(screen.queryByPlaceholderText(/searchPlaceholder/)).not.toBeInTheDocument(); + }); + + it('omits the tooltip when not disabled', () => { + render( + , + ); + const toggle = screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i }); + expect(toggle).not.toBeDisabled(); + expect(toggle).not.toHaveAttribute('title'); + }); + + it('treats omitted `disabled` prop as enabled (backwards-compat)', () => { + render( + , + ); + expect(screen.getByRole('button', { name: /wiz\.quickstart\.toggle/i })).not.toBeDisabled(); + }); +}); diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts index 3fb63c54..45da300c 100644 --- a/frontend/src/lib/__tests__/api.test.ts +++ b/frontend/src/lib/__tests__/api.test.ts @@ -153,6 +153,68 @@ describe('api module', () => { await expect(projects.list()).rejects.toThrow('Server error (HTTP 502)'); }); + // 0.8.5 — Axum returns 422 + Content-Type text/plain when the JSON + // extractor fails to deserialize the request body. The body holds + // the actual reason ("missing field `agent`"). Pre-fix we threw + // away that body and the QP-Improver agent on the JIRA helper had + // no clue what to fix → went in circles. + it('surfaces the body in the error message when Content-Type is not JSON', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + status: 422, + headers: { + get: (name: string) => name === 'content-type' ? 'text/plain' : null, + }, + text: () => Promise.resolve('Failed to deserialize the JSON body: missing field `agent` at line 1 column 234'), + }); + const { projects } = await getApi(); + await expect(projects.list()).rejects.toThrow(/missing field `agent`/); + }); + + it('truncates non-JSON error bodies to 500 chars', async () => { + const huge = 'X'.repeat(2000); + (globalThis.fetch as ReturnType).mockResolvedValue({ + status: 500, + headers: { + get: (name: string) => name === 'content-type' ? 'text/html' : null, + }, + text: () => Promise.resolve(huge), + }); + const { projects } = await getApi(); + try { + await projects.list(); + throw new Error('expected throw'); + } catch (e) { + const msg = (e as Error).message; + expect(msg).toContain('Server error (HTTP 500) — '); + // 500-char body + "Server error (HTTP 500) — " prefix + expect(msg.length).toBeLessThanOrEqual(540); + } + }); + + it('omits the body suffix when the response body is empty', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + status: 502, + headers: { + get: (name: string) => name === 'content-type' ? 'text/html' : null, + }, + text: () => Promise.resolve(''), + }); + const { projects } = await getApi(); + await expect(projects.list()).rejects.toThrow(/^Server error \(HTTP 502\)$/); + }); + + it('omits the body suffix when text() throws (defensive)', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + status: 502, + headers: { + get: (name: string) => name === 'content-type' ? 'text/html' : null, + }, + text: () => Promise.reject(new Error('stream error')), + }); + const { projects } = await getApi(); + await expect(projects.list()).rejects.toThrow(/^Server error \(HTTP 502\)$/); + }); + it('throws "Unknown API error" when error field is null', async () => { (globalThis.fetch as ReturnType).mockResolvedValue({ status: 500, diff --git a/frontend/src/lib/__tests__/workflow-quick-start.test.ts b/frontend/src/lib/__tests__/workflow-quick-start.test.ts new file mode 100644 index 00000000..e9c6ad8b --- /dev/null +++ b/frontend/src/lib/__tests__/workflow-quick-start.test.ts @@ -0,0 +1,327 @@ +// 0.8.5 — unit tests pinning the 3-source adapter + catalogue +// sort/filter contract that the WorkflowQuickStartPicker depends on. + +import { describe, it, expect } from 'vitest'; +import { + buildQuickStartCatalogue, + filterQuickStart, + quickStartStepsPreview, + __testing, + type UnifiedQuickStart, +} from '../workflow-quick-start'; +import type { StarterTemplate } from '../workflow-templates/chartbeat-top5'; +import type { WorkflowPreset } from '../workflow-templates/v07-presets'; +import type { WorkflowSuggestion, WorkflowStep } from '../../types/generated'; +import type { ApiPluginOption } from '../../components/workflows/ApiCallStepCard'; + +// ─── Fixture helpers ─────────────────────────────────────────────────── + +function makeStep(name: string): WorkflowStep { + return { + name, + agent: 'ClaudeCode', + prompt_template: '', + mode: { type: 'Normal' }, + } as WorkflowStep; +} + +function makeStarter(overrides: Partial = {}): StarterTemplate { + return { + id: 'starter-id', + title_fr: 'Démo Chartbeat', + title_en: 'Chartbeat demo', + description_fr: 'Récupère le top 5 et résume.', + description_en: 'Pulls top 5 and summarises.', + primary_plugin_slug: 'mcp-chartbeat', + steps: [makeStep('fetch'), makeStep('summarize'), makeStep('notify')], + ...overrides, + }; +} + +function makeSuggestion(overrides: Partial = {}): WorkflowSuggestion { + return { + id: 'sug-1', + title: 'Triage des issues', + description: 'Triage automatique des nouvelles issues.', + reason: 'Le projet a un tracker GitHub configuré.', + required_mcps: ['mcp-github'], + audience: 'dev', + complexity: 'simple', + trigger: { type: 'Manual' } as WorkflowSuggestion['trigger'], + steps: [makeStep('triage')], + ...overrides, + }; +} + +function makePreset(overrides: Partial = {}): WorkflowPreset { + return { + id: 'auto-dev', + icon: '🤖', + titleKey: 'wiz.preset.autoDev.title', + descKey: 'wiz.preset.autoDev.desc', + primitives: ['Agent', 'Exec'], + steps: [makeStep('main'), makeStep('test')], + ...overrides, + }; +} + +function makePlugin(slug: string): ApiPluginOption { + return { + server: { id: slug } as ApiPluginOption['server'], + config: { id: `cfg-${slug}` } as ApiPluginOption['config'], + }; +} + +// Minimal i18n translator stub. The real `useT()` resolves keys against +// `i18n.ts`; for these unit tests we return a deterministic synthetic +// string per key so we can pin "key X was resolved" without depending +// on the live translation table. `wiz.preset..title` keys map to +// the segment after the last dot for a readable assertion target. +function tStub(key: string, ..._args: (string | number)[]): string { + if (key.endsWith('.title')) { + const segments = key.split('.'); + return segments[segments.length - 2] ?? key; + } + return key; +} + +const emptyCtx = { + projectSelected: false, + availableApiPlugins: [] as ApiPluginOption[], +}; + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe('buildQuickStartCatalogue', () => { + it('returns an empty array when no sources are given', () => { + const out = buildQuickStartCatalogue({ starters: [], suggestions: [], presets: [], ctx: emptyCtx, t: tStub }); + expect(out).toEqual([]); + }); + + it('aggregates entries from all three sources', () => { + const out = buildQuickStartCatalogue({ + starters: [makeStarter()], + suggestions: [makeSuggestion()], + presets: [makePreset()], + ctx: emptyCtx, + t: tStub, + }); + expect(out).toHaveLength(3); + expect(out.map(e => e.source).sort()).toEqual( + ['preset', 'project-suggestion', 'starter'].sort(), + ); + }); + + it('namespaces ids per source to avoid collisions', () => { + const out = buildQuickStartCatalogue({ + starters: [makeStarter({ id: 'same' })], + suggestions: [makeSuggestion({ id: 'same' })], + presets: [makePreset({ id: 'auto-dev' })], + ctx: emptyCtx, + t: tStub, + }); + const ids = out.map(e => e.id); + expect(new Set(ids).size).toBe(ids.length); // unique + expect(ids).toContain('starter:same'); + expect(ids).toContain('suggestion:same'); + expect(ids).toContain('preset:auto-dev'); + }); + + it('sorts simple → intermediate → advanced, then by stepsCount asc, then title', () => { + const entries = buildQuickStartCatalogue({ + starters: [], + suggestions: [ + makeSuggestion({ id: 'a', title: 'Z-simple-1step', complexity: 'simple', steps: [makeStep('s')] }), + makeSuggestion({ id: 'b', title: 'A-advanced-2', complexity: 'advanced', steps: [makeStep('s1'), makeStep('s2')] }), + makeSuggestion({ id: 'c', title: 'M-simple-1step', complexity: 'simple', steps: [makeStep('s')] }), + ], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + // Both 'a' and 'c' are simple/1step → tie-break on title (M before Z). + expect(entries.map(e => e.id)).toEqual(['suggestion:c', 'suggestion:a', 'suggestion:b']); + }); + + it('flags starters as not applicable when the primary plugin is missing', () => { + const out = buildQuickStartCatalogue({ + starters: [makeStarter({ primary_plugin_slug: 'mcp-chartbeat' })], + suggestions: [], + presets: [], + ctx: { projectSelected: false, availableApiPlugins: [] }, + t: tStub, + }); + expect(out[0].applicable).toBe(false); + expect(out[0].notApplicableReason).toMatch(/mcp-chartbeat/); + }); + + it('flags starters as applicable when the primary plugin IS available', () => { + const out = buildQuickStartCatalogue({ + starters: [makeStarter({ primary_plugin_slug: 'mcp-chartbeat' })], + suggestions: [], + presets: [], + ctx: { projectSelected: false, availableApiPlugins: [makePlugin('mcp-chartbeat')] }, + t: tStub, + }); + expect(out[0].applicable).toBe(true); + expect(out[0].notApplicableReason).toBeUndefined(); + }); + + it('promotes a 4+ step "simple" suggestion to advanced', () => { + // Backend says simple but the chain is long — picker treats as advanced + // so it sorts at the bottom and badges accordingly. + const out = buildQuickStartCatalogue({ + starters: [], + suggestions: [makeSuggestion({ + complexity: 'simple', + steps: [makeStep('a'), makeStep('b'), makeStep('c'), makeStep('d')], + })], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(out[0].complexity).toBe('advanced'); + }); + + it('promotes a 2-3 step "simple" suggestion to intermediate', () => { + const out = buildQuickStartCatalogue({ + starters: [], + suggestions: [makeSuggestion({ + complexity: 'simple', + steps: [makeStep('a'), makeStep('b')], + })], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(out[0].complexity).toBe('intermediate'); + }); + + it('flags a preset with on_failure as advanced regardless of step count', () => { + const out = buildQuickStartCatalogue({ + starters: [], + suggestions: [], + presets: [makePreset({ + steps: [makeStep('s1')], // 1 step + onFailure: [makeStep('rollback')], + })], + ctx: emptyCtx, + t: tStub, + }); + expect(out[0].complexity).toBe('advanced'); + }); + + it('preserves suggestion reason on the unified entry', () => { + const out = buildQuickStartCatalogue({ + starters: [], + suggestions: [makeSuggestion({ reason: 'Le projet utilise déjà Slack.' })], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(out[0].reason).toBe('Le projet utilise déjà Slack.'); + }); + + it('caps starter badges + suggestion mcps to keep rows compact', () => { + const out = buildQuickStartCatalogue({ + starters: [], + suggestions: [makeSuggestion({ + required_mcps: ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'], + })], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(out[0].badges).toHaveLength(4); // capped at 4 + }); + + it('payload roundtrips the native objects verbatim', () => { + const starter = makeStarter(); + const suggestion = makeSuggestion(); + const preset = makePreset(); + const out = buildQuickStartCatalogue({ + starters: [starter], + suggestions: [suggestion], + presets: [preset], + ctx: emptyCtx, + t: tStub, + }); + const byKind = Object.fromEntries(out.map(e => [e.payload.kind, e.payload])); + expect((byKind['starter'] as { template: StarterTemplate }).template).toBe(starter); + expect((byKind['project-suggestion'] as { suggestion: WorkflowSuggestion }).suggestion).toBe(suggestion); + expect((byKind['preset'] as { preset: WorkflowPreset }).preset).toBe(preset); + }); +}); + +describe('filterQuickStart', () => { + function fixture(): UnifiedQuickStart[] { + return buildQuickStartCatalogue({ + starters: [makeStarter({ id: 's', title_fr: 'Chartbeat top 5' })], + suggestions: [makeSuggestion({ id: 'g', title: 'Triage des issues' })], + presets: [makePreset({ id: 'auto-dev' })], + ctx: emptyCtx, + t: tStub, + }); + } + + it('returns the input untouched on empty query', () => { + const list = fixture(); + expect(filterQuickStart(list, '')).toEqual(list); + expect(filterQuickStart(list, ' ')).toEqual(list); + }); + + it('matches the title case-insensitively', () => { + const out = filterQuickStart(fixture(), 'CHARTBEAT'); + expect(out).toHaveLength(1); + expect(out[0].source).toBe('starter'); + }); + + it('matches the description', () => { + const out = filterQuickStart(fixture(), 'triage'); + expect(out).toHaveLength(1); + expect(out[0].source).toBe('project-suggestion'); + }); + + it('matches a badge', () => { + const out = filterQuickStart(fixture(), 'mcp-github'); + expect(out).toHaveLength(1); + expect(out[0].source).toBe('project-suggestion'); + }); + + it('returns empty when no entry matches', () => { + expect(filterQuickStart(fixture(), 'zzzzzzzzz')).toEqual([]); + }); +}); + +describe('quickStartStepsPreview', () => { + it('returns the first N step names', () => { + const entries = buildQuickStartCatalogue({ + starters: [makeStarter({ steps: [makeStep('a'), makeStep('b'), makeStep('c'), makeStep('d'), makeStep('e')] })], + suggestions: [], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(quickStartStepsPreview(entries[0], 3)).toEqual(['a', 'b', 'c']); + }); + + it('returns all step names if the chain is shorter than max', () => { + const entries = buildQuickStartCatalogue({ + starters: [], + suggestions: [makeSuggestion({ steps: [makeStep('only')] })], + presets: [], + ctx: emptyCtx, + t: tStub, + }); + expect(quickStartStepsPreview(entries[0], 4)).toEqual(['only']); + }); +}); + +describe('comparator stability', () => { + it('ranks complexities simple < intermediate < advanced', () => { + expect(__testing.COMPLEXITY_ORDER.simple) + .toBeLessThan(__testing.COMPLEXITY_ORDER.intermediate); + expect(__testing.COMPLEXITY_ORDER.intermediate) + .toBeLessThan(__testing.COMPLEXITY_ORDER.advanced); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d6d11952..aac95bd6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -311,7 +311,19 @@ async function api( const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) { - throw new Error(`Server error (HTTP ${res.status})`); + // 0.8.5 — when axum's `Json` extractor rejects a request + // (missing field, unknown enum variant, type mismatch), it + // returns 422 with `Content-Type: text/plain` and the actual + // deserialization failure in the body. Pre-fix we threw away + // the body and surfaced a bare "Server error (HTTP 422)" with + // zero actionable info — exactly what tripped the QP-Improver + // agent on the JIRA helper during 0.8.4 dogfooding. Same path + // also covers gateway-style 5xx HTML bodies; we cap at 500 + // chars so a 10MB nginx error page doesn't drown the toast. + const body = await res.text().catch(() => ''); + const trimmed = body.trim(); + const suffix = trimmed ? ` — ${trimmed.slice(0, 500)}` : ''; + throw new Error(`Server error (HTTP ${res.status})${suffix}`); } const json: ApiResponse = await res.json(); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index e593e4b3..3bd2e9f7 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -474,6 +474,11 @@ const fr: TranslationDict = { 'disc.scrollToTop': 'Aller au début', 'disc.scrollToBottom': 'Aller à la fin', 'disc.introspectionPillTooltip': '{0} appel{1} aux outils d\'historique (kronn-internal MCP)', + // 0.8.5 — disc-id pill in ChatHeader + sidebar tooltip + search by id prefix. + 'disc.idPillTooltip': 'ID complet : {0} · clic pour copier', + 'disc.idCopied': 'ID copié dans le presse-papiers', + 'disc.idCopyFailed': 'Copie impossible (accès au presse-papiers refusé)', + 'disc.titleHoverTooltip': '{0}\nID : {1}', 'disc.summaryStrategyLabel': 'Synthèse auto', 'disc.summaryStrategy.Auto.label': '⚡ Auto', 'disc.summaryStrategy.Auto.hint': 'Synthèse automatique tous les N messages selon le budget contexte de l\'agent. Idéal pour les debate ou les petits modèles.', @@ -1518,6 +1523,28 @@ suggestion KRONN:APPLY ; cela écraserait la vraie valeur par un placeholder 'wiz.helpTriggerTracker': 'Le workflow se déclenche quand une nouvelle issue apparaît sur GitHub (ou autre tracker) avec certains labels. Idéal pour du triage ou de l\'auto-fix.', 'wiz.suggestionsCount': '{0} suggestion(s) disponible(s)', 'wiz.suggestionsTitle': 'Suggestions pour ce projet', + // 0.8.5 — unified QuickStart picker (replaces 3 separate UI sections). + 'wiz.quickstart.toggle': '✨ {0} modèles disponibles pour démarrer', + 'wiz.quickstart.title': 'Démarrer rapidement', + 'wiz.quickstart.hint': 'Sélectionne un modèle pour pré-remplir le workflow.', + 'wiz.quickstart.collapse': 'Réduire', + 'wiz.quickstart.searchPlaceholder': 'Rechercher un modèle…', + 'wiz.quickstart.filterComplexity': 'Complexité :', + 'wiz.quickstart.filterSource': 'Source :', + 'wiz.quickstart.complexity.simple': 'Simple', + 'wiz.quickstart.complexity.intermediate': 'Intermédiaire', + 'wiz.quickstart.complexity.advanced': 'Avancé', + 'wiz.quickstart.source.starter': 'Démo plugin', + 'wiz.quickstart.source.project-suggestion': 'Suggestion projet', + 'wiz.quickstart.source.preset': 'Préset', + 'wiz.quickstart.complexityTooltip': 'Niveau de configuration estimé', + 'wiz.quickstart.sourceTooltip': 'Origine du modèle', + 'wiz.quickstart.stepsCount': '{0} étape(s)', + 'wiz.quickstart.empty': 'Aucun modèle ne correspond. Essaie de relâcher les filtres ou démarre de zéro en saisissant juste un nom.', + 'wiz.quickstart.loading': 'Chargement des suggestions…', + 'wiz.quickstart.apply': 'Utiliser ce modèle', + 'wiz.quickstart.notApplicable': 'Non disponible : {0}', + 'wiz.quickstart.disabledNoName': 'Saisissez un nom de workflow avant de sélectionner un modèle pré-construit.', 'wiz.activate': 'Activer', 'wiz.importDraft': 'Importer comme brouillon', 'wiz.outputFormat': 'Format de sortie', @@ -1775,16 +1802,16 @@ Termine par [SIGNAL: OK].`, 'wiz.preset.ticketToPr.planGateMessage': '## Validation du plan\n\n**Récap :** {{steps.analyze.summary}}\n\n**Sous-tâches :**\n{{steps.analyze.data.subtasks}}\n\n**Stratégie de test :**\n{{steps.analyze.data.test_strategy}}\n\nApprouve pour lancer l\'implémentation, ou demande des changements pour relancer l\'analyse avec ton feedback.', 'wiz.preset.ticketToPr.implementPrompt': 'Implémente le plan validé :\n---\n{{steps.analyze.data}}\n---\n\nSi une review précédente t\'a laissé du feedback à adresser :\n{{state.last_review}}\n\n# Tu vas charger automatiquement :\n- `test-driven-development` (rituel red-green-refactor strict)\n- `systematic-debugging` (root-cause à 4 phases si un test casse)\n- `verification-before-completion` (no claim sans evidence)\n- `receiving-code-review` (technique pour appliquer les retours review)\n\n# Procédure :\n1. Pour CHAQUE sous-tâche du plan : écris d\'abord les tests (red), puis le code minimal (green), puis refactor.\n2. Lance les tests à chaque étape, vérifie l\'output réellement.\n3. Si un test casse de manière inattendue, applique systematic-debugging (pas de fix au pifomètre).\n4. À la fin, output `[SIGNAL: CONTINUE]` pour passer aux tests d\'intégration.', 'wiz.preset.ticketToPr.reviewPrompt': 'Review l\'implémentation produite par `implement` et le résultat des tests `run_tests`.\n\nContexte :\n- Plan original : {{steps.analyze.summary}}\n- Tests output : {{steps.run_tests.data.stdout}}\n- Tests exit code : {{steps.run_tests.data.exit_code}}\n\n# Tu vas charger automatiquement les skills `requesting-code-review` et `verification-before-completion`.\n\n# Procédure :\n1. Vérifie que l\'implémentation couvre TOUTES les sous-tâches du plan (pas une partielle).\n2. Vérifie les tests : ils testent un comportement réel, pas juste des mocks ?\n3. YAGNI check : l\'implémentation n\'a pas ajouté de complexité non demandée ?\n4. Sécu : injections, fuites de secrets, validation d\'entrées ?\n5. Edge cases : null, empty, unicode, large input ?\n\n# Verdict :\nSi tout OK, termine ta réponse par exactement `[SIGNAL: APPROVED]`.\nSinon, écris un feedback actionnable, écris exactement à la fin (et UNIQUEMENT à la fin) :\n```\n---STATE:last_review=---\n[SIGNAL: NEEDS_CHANGES]\n```', - 'wiz.preset.ticketToPr.createPrPrompt': 'L\'implémentation est validée et tous les tests passent. Crée la PR.\n\nContexte :\n- Ticket : {{steps.fetch_issue.data}}\n- Récap implémentation : {{steps.implement.summary}}\n- Review approuvée : {{steps.review.summary}}\n\n# Tu vas charger automatiquement `finishing-a-development-branch` et `verification-before-completion`.\n\n# Procédure :\n1. Vérifie une dernière fois que les tests passent (commande exacte, output complet).\n2. Push la branche actuelle.\n3. Crée la PR via `gh pr create` avec :\n - Titre cohérent (référence le ticket)\n - Body structuré : Summary (3 bullets) + Test Plan (checklist)\n4. Output `state.pr_url=` et `state.pr_number=`.\n5. Termine par `[SIGNAL: PR_CREATED]`.', + 'wiz.preset.ticketToPr.createPrPrompt': 'L\'implémentation est validée et tous les tests passent. Crée la PR.\n\nContexte :\n- Ticket : {{steps.fetch_issue.data}}\n- Récap implémentation : {{steps.implement.summary}}\n- Review approuvée : {{steps.review.summary}}\n\n# Tu vas charger automatiquement `finishing-a-development-branch` et `verification-before-completion`.\n\n# Procédure :\n1. Vérifie une dernière fois que les tests passent (commande exacte, output complet).\n2. Push la branche actuelle.\n3. Crée la PR via `gh pr create` avec :\n - Titre cohérent (référence le ticket)\n - Body structuré : Summary (3 bullets) + Test Plan (checklist)\n4. Émets les markers Kronn pour exposer l\'URL au step suivant — chaque marker sur sa propre ligne, exactement ce format :\n ```\n ---STATE:pr_url=---\n ---STATE:pr_number=---\n ```\n (Important : `state.pr_url=` SANS les `---STATE:...---` ne sera PAS lu par Kronn.)\n5. Termine par `[SIGNAL: PR_CREATED]`.', 'wiz.preset.ticketToPr.readyGateMessage': '## PR prête au merge\n\n**PR :** {{state.pr_url}}\n**Récap :** {{steps.review.summary}}\n\nApprouve pour finaliser (notif de succès), ou demande des changements pour relancer `implement` avec ton feedback (le `state.last_review` sera consommé par l\'agent).', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot terminé : {{state.pr_url}} — {{steps.review.summary}}', 'wiz.preset.ticketToPr.rollbackBody': '❌ Ticket Autopilot a échoué sur `{{failed_step.name}}` — détail : {{failed_step.output}}', // 0.8.3 — Feasibility-Gated AutoPilot (auto-proposé après l'audit IA) 'wiz.preset.feasibilityAutopilot.title': 'Big-ticket AutoPilot — feasibility-gated', 'wiz.preset.feasibilityAutopilot.desc': 'Pour les gros tickets : un step `triage` classe chaque sous-tâche en clear / decided / mocked / blocked AVANT toute ligne de code. La gate humaine valide le plan, puis `implement` insère des markers `KRONN-(ASSUMED|MOCKED|TODO)` à chaque liberté tracée. `run_tests` Exec lance la vraie suite, `drift_check` Exec liste les markers dans la PR finale. Token-cost minimal — seuls triage / implement / pr_draft sont Agent.', - 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Source du ticket. JsonData fixture par défaut ; le wizard swap automatiquement en ApiCall si un plugin tracker (Jira/GitHub/GitLab) est wiré sur le projet. Le step suivant lit `{{steps.fetch_issue.data.body}}`.', + 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Source du ticket. JsonData fixture par défaut ; le wizard swap automatiquement en ApiCall si un plugin tracker (Jira/GitHub/GitLab) est wiré sur le projet. Le step suivant lit `{{steps.fetch_issue.data}}` (objet complet — l\'agent navigue les champs selon la source : `body` pour le fixture, `fields.description` ou `renderedFields.description` pour Jira).', 'wiz.preset.feasibilityAutopilot.triageDesc': '[TRIAGE] Audit de faisabilité — classe chaque sous-tâche en clear / decided / mocked / blocked. L\'agent ne CODE pas ici ; il produit un manifest JSON validé par schema (on_invalid=Fail). Chaque entry porte un `id` stable que `implement` utilisera comme decision_id dans les markers.', - 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket body (from `fetch_issue`):\n---\n{{steps.fetch_issue.data.body}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', + 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket payload (from `fetch_issue` — full data object, navigate as needed for `body` / `description` / `fields.description` / `renderedFields.description` depending on source):\n---\n{{steps.fetch_issue.data}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Revue humaine du manifest triage avant le code. `gate_request_changes_target: triage` permet de boucler max 5 fois.', 'wiz.preset.feasibilityAutopilot.gateMessage': 'Triage manifest produit. Revois les 4 catégories ci-dessous avant d\'approuver :\n\n{{steps.triage.data}}\n\n- Approve : lance l\'implémentation telle quelle.\n- Request changes : renvoie au triage avec tes notes (max 5 boucles).\n- Reject : abandonne le run.', 'wiz.preset.feasibilityAutopilot.implementDesc': 'Implémentation contrainte par le manifest validé. Chaque entry decided/mocked/blocked DOIT avoir son marker `KRONN-*` dans le code. `[SIGNAL: BLOCKED ]` → Goto(triage) si l\'agent découvre une impossibilité (cap 3 itérations).', @@ -2613,6 +2640,11 @@ const en: TranslationDict = { 'disc.scrollToTop': 'Jump to top', 'disc.scrollToBottom': 'Jump to bottom', 'disc.introspectionPillTooltip': '{0} history-tool call{1} (kronn-internal MCP)', + // 0.8.5 — disc-id pill in ChatHeader + sidebar tooltip + search by id prefix. + 'disc.idPillTooltip': 'Full ID: {0} · click to copy', + 'disc.idCopied': 'ID copied to clipboard', + 'disc.idCopyFailed': 'Copy failed (clipboard access denied)', + 'disc.titleHoverTooltip': '{0}\nID: {1}', 'disc.summaryStrategyLabel': 'Auto-summary', 'disc.summaryStrategy.Auto.label': '⚡ Auto', 'disc.summaryStrategy.Auto.hint': 'Auto-fire every N messages based on the agent\'s context budget. Best for debate or small models.', @@ -3655,6 +3687,28 @@ suggestion; that would overwrite the real value with a placeholder 'wiz.helpTriggerTracker': 'The workflow fires when a new issue appears on GitHub (or another tracker) with certain labels. Ideal for triage or auto-fix.', 'wiz.suggestionsCount': '{0} suggestion(s) available', 'wiz.suggestionsTitle': 'Suggestions for this project', + // 0.8.5 — unified QuickStart picker (replaces 3 separate UI sections). + 'wiz.quickstart.toggle': '✨ {0} ready-made templates available', + 'wiz.quickstart.title': 'Quick start', + 'wiz.quickstart.hint': 'Pick a template to pre-fill the workflow.', + 'wiz.quickstart.collapse': 'Collapse', + 'wiz.quickstart.searchPlaceholder': 'Search templates…', + 'wiz.quickstart.filterComplexity': 'Complexity:', + 'wiz.quickstart.filterSource': 'Source:', + 'wiz.quickstart.complexity.simple': 'Simple', + 'wiz.quickstart.complexity.intermediate': 'Intermediate', + 'wiz.quickstart.complexity.advanced': 'Advanced', + 'wiz.quickstart.source.starter': 'Plugin demo', + 'wiz.quickstart.source.project-suggestion': 'Project suggestion', + 'wiz.quickstart.source.preset': 'Preset', + 'wiz.quickstart.complexityTooltip': 'Estimated configuration depth', + 'wiz.quickstart.sourceTooltip': 'Where this template comes from', + 'wiz.quickstart.stepsCount': '{0} step(s)', + 'wiz.quickstart.empty': 'No template matches. Try removing filters or start from scratch by typing a name.', + 'wiz.quickstart.loading': 'Loading suggestions…', + 'wiz.quickstart.apply': 'Use this template', + 'wiz.quickstart.notApplicable': 'Unavailable: {0}', + 'wiz.quickstart.disabledNoName': 'Enter a workflow name before selecting a ready-made template.', 'wiz.activate': 'Activate', 'wiz.importDraft': 'Import as draft', 'wiz.outputFormat': 'Output format', @@ -3912,16 +3966,16 @@ End with [SIGNAL: OK].`, 'wiz.preset.ticketToPr.planGateMessage': '## Plan validation\n\n**Recap:** {{steps.analyze.summary}}\n\n**Subtasks:**\n{{steps.analyze.data.subtasks}}\n\n**Test strategy:**\n{{steps.analyze.data.test_strategy}}\n\nApprove to start implementation, or request changes to re-run analysis with your feedback.', 'wiz.preset.ticketToPr.implementPrompt': 'Implement the validated plan:\n---\n{{steps.analyze.data}}\n---\n\nIf a previous review left feedback to address:\n{{state.last_review}}\n\n# You will auto-load:\n- `test-driven-development` (strict red-green-refactor ritual)\n- `systematic-debugging` (4-phase root-cause when a test breaks)\n- `verification-before-completion` (no claim without evidence)\n- `receiving-code-review` (technique for applying review feedback)\n\n# Procedure:\n1. For EACH subtask in the plan: write tests first (red), then minimal code (green), then refactor.\n2. Run tests at each step, actually verify the output.\n3. If a test breaks unexpectedly, apply systematic-debugging (no shotgun fixes).\n4. At the end, output `[SIGNAL: CONTINUE]` to move to integration tests.', 'wiz.preset.ticketToPr.reviewPrompt': 'Review the implementation produced by `implement` and the result of the `run_tests` step.\n\nContext:\n- Original plan: {{steps.analyze.summary}}\n- Tests output: {{steps.run_tests.data.stdout}}\n- Tests exit code: {{steps.run_tests.data.exit_code}}\n\n# You will auto-load `requesting-code-review` and `verification-before-completion` skills.\n\n# Procedure:\n1. Verify the implementation covers ALL subtasks in the plan (not partial).\n2. Verify tests: do they test real behavior, not just mocks?\n3. YAGNI check: did the implementation add unrequested complexity?\n4. Security: injections, secret leaks, input validation?\n5. Edge cases: null, empty, unicode, large input?\n\n# Verdict:\nIf everything OK, end your reply with exactly `[SIGNAL: APPROVED]`.\nOtherwise, write actionable feedback, then exactly at the end (and ONLY at the end):\n```\n---STATE:last_review=---\n[SIGNAL: NEEDS_CHANGES]\n```', - 'wiz.preset.ticketToPr.createPrPrompt': 'The implementation is validated and all tests pass. Create the PR.\n\nContext:\n- Ticket: {{steps.fetch_issue.data}}\n- Implementation recap: {{steps.implement.summary}}\n- Approved review: {{steps.review.summary}}\n\n# You will auto-load `finishing-a-development-branch` and `verification-before-completion`.\n\n# Procedure:\n1. Verify one last time that tests pass (exact command, complete output).\n2. Push the current branch.\n3. Create the PR via `gh pr create` with:\n - Coherent title (reference the ticket)\n - Structured body: Summary (3 bullets) + Test Plan (checklist)\n4. Output `state.pr_url=` and `state.pr_number=`.\n5. End with `[SIGNAL: PR_CREATED]`.', + 'wiz.preset.ticketToPr.createPrPrompt': 'The implementation is validated and all tests pass. Create the PR.\n\nContext:\n- Ticket: {{steps.fetch_issue.data}}\n- Implementation recap: {{steps.implement.summary}}\n- Approved review: {{steps.review.summary}}\n\n# You will auto-load `finishing-a-development-branch` and `verification-before-completion`.\n\n# Procedure:\n1. Verify one last time that tests pass (exact command, complete output).\n2. Push the current branch.\n3. Create the PR via `gh pr create` with:\n - Coherent title (reference the ticket)\n - Structured body: Summary (3 bullets) + Test Plan (checklist)\n4. Emit the Kronn STATE markers so the next step can consume the URL — each on its own line, exactly this format:\n ```\n ---STATE:pr_url=---\n ---STATE:pr_number=---\n ```\n (Important: writing `state.pr_url=` WITHOUT the `---STATE:...---` markers will NOT be parsed by Kronn.)\n5. End with `[SIGNAL: PR_CREATED]`.', 'wiz.preset.ticketToPr.readyGateMessage': '## PR ready to merge\n\n**PR:** {{state.pr_url}}\n**Recap:** {{steps.review.summary}}\n\nApprove to finalize (success notification), or request changes to re-run `implement` with your feedback (`state.last_review` will be consumed by the agent).', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot complete: {{state.pr_url}} — {{steps.review.summary}}', 'wiz.preset.ticketToPr.rollbackBody': '❌ Ticket Autopilot failed at `{{failed_step.name}}` — details: {{failed_step.output}}', // 0.8.3 — Feasibility-Gated AutoPilot (auto-proposed after AI audit) 'wiz.preset.feasibilityAutopilot.title': 'Big-ticket AutoPilot — feasibility-gated', 'wiz.preset.feasibilityAutopilot.desc': 'For big tickets: a `triage` step classifies every sub-task into clear / decided / mocked / blocked BEFORE any code is written. The human gate validates the plan, then `implement` inserts `KRONN-(ASSUMED|MOCKED|TODO)` markers at every traced freedom. `run_tests` Exec runs the real suite, `drift_check` Exec lists the markers in the final PR. Minimal token cost — only triage / implement / pr_draft are Agent.', - 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Ticket source. JsonData fixture by default; the wizard auto-swaps to ApiCall when a tracker plugin (Jira/GitHub/GitLab) is wired for the project. The next step reads `{{steps.fetch_issue.data.body}}`.', + 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Ticket source. JsonData fixture by default; the wizard auto-swaps to ApiCall when a tracker plugin (Jira/GitHub/GitLab) is wired for the project. The next step reads `{{steps.fetch_issue.data}}` (full object — the agent navigates the fields depending on source: `body` for the fixture, `fields.description` or `renderedFields.description` for Jira).', 'wiz.preset.feasibilityAutopilot.triageDesc': '[TRIAGE] Feasibility audit — classifies every sub-task into clear / decided / mocked / blocked. The agent does NOT write code here; it produces a schema-validated JSON manifest (on_invalid=Fail). Each entry has a stable `id` that `implement` will use as the decision_id in markers.', - 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket body (from `fetch_issue`):\n---\n{{steps.fetch_issue.data.body}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', + 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket payload (from `fetch_issue` — full data object, navigate as needed for `body` / `description` / `fields.description` / `renderedFields.description` depending on source):\n---\n{{steps.fetch_issue.data}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Human review of the triage manifest before code. `gate_request_changes_target: triage` allows up to 5 loops.', 'wiz.preset.feasibilityAutopilot.gateMessage': 'Triage manifest produced. Review the four categories below before approving:\n\n{{steps.triage.data}}\n\n- Approve: continue to implementation as-is.\n- Request changes: send back to triage with your notes (loops up to 5 times).\n- Reject: abandon the run.', 'wiz.preset.feasibilityAutopilot.implementDesc': 'Implementation constrained by the validated manifest. Every decided/mocked/blocked entry MUST have its `KRONN-*` marker in the code. `[SIGNAL: BLOCKED ]` → Goto(triage) if the agent discovers an impossibility (cap 3 iterations).', @@ -4749,6 +4803,11 @@ const es: TranslationDict = { 'disc.scrollToTop': 'Ir al principio', 'disc.scrollToBottom': 'Ir al final', 'disc.introspectionPillTooltip': '{0} llamada{1} a herramientas de historial (kronn-internal MCP)', + // 0.8.5 — disc-id pill in ChatHeader + sidebar tooltip + search by id prefix. + 'disc.idPillTooltip': 'ID completo: {0} · clic para copiar', + 'disc.idCopied': 'ID copiado al portapapeles', + 'disc.idCopyFailed': 'Copia fallida (acceso al portapapeles denegado)', + 'disc.titleHoverTooltip': '{0}\nID: {1}', 'disc.summaryStrategyLabel': 'Resumen auto', 'disc.summaryStrategy.Auto.label': '⚡ Auto', 'disc.summaryStrategy.Auto.hint': 'Resumen automático cada N mensajes según el contexto del agente. Ideal para debate o modelos pequeños.', @@ -5792,6 +5851,28 @@ KRONN:APPLY; eso sobreescribiría el valor real con un placeholder 'wiz.helpTriggerTracker': 'El workflow se dispara cuando aparece una nueva issue en GitHub (u otro tracker) con ciertos labels. Ideal para triage o auto-fix.', 'wiz.suggestionsCount': '{0} sugerencia(s) disponible(s)', 'wiz.suggestionsTitle': 'Sugerencias para este proyecto', + // 0.8.5 — unified QuickStart picker (replaces 3 separate UI sections). + 'wiz.quickstart.toggle': '✨ {0} plantillas disponibles para empezar', + 'wiz.quickstart.title': 'Inicio rápido', + 'wiz.quickstart.hint': 'Selecciona una plantilla para prellenar el workflow.', + 'wiz.quickstart.collapse': 'Contraer', + 'wiz.quickstart.searchPlaceholder': 'Buscar plantilla…', + 'wiz.quickstart.filterComplexity': 'Complejidad:', + 'wiz.quickstart.filterSource': 'Origen:', + 'wiz.quickstart.complexity.simple': 'Simple', + 'wiz.quickstart.complexity.intermediate': 'Intermedia', + 'wiz.quickstart.complexity.advanced': 'Avanzada', + 'wiz.quickstart.source.starter': 'Demo plugin', + 'wiz.quickstart.source.project-suggestion': 'Sugerencia proyecto', + 'wiz.quickstart.source.preset': 'Preset', + 'wiz.quickstart.complexityTooltip': 'Nivel de configuración estimado', + 'wiz.quickstart.sourceTooltip': 'Origen de la plantilla', + 'wiz.quickstart.stepsCount': '{0} paso(s)', + 'wiz.quickstart.empty': 'Ninguna plantilla coincide. Quita los filtros o empieza desde cero escribiendo un nombre.', + 'wiz.quickstart.loading': 'Cargando sugerencias…', + 'wiz.quickstart.apply': 'Usar esta plantilla', + 'wiz.quickstart.notApplicable': 'No disponible: {0}', + 'wiz.quickstart.disabledNoName': 'Escribe un nombre de workflow antes de seleccionar una plantilla pre-construida.', 'wiz.activate': 'Activar', 'wiz.importDraft': 'Importar como borrador', 'wiz.outputFormat': 'Formato de salida', @@ -6049,16 +6130,16 @@ Termina con [SIGNAL: OK].`, 'wiz.preset.ticketToPr.planGateMessage': '## Validación del plan\n\n**Resumen:** {{steps.analyze.summary}}\n\n**Sub-tareas:**\n{{steps.analyze.data.subtasks}}\n\n**Estrategia de tests:**\n{{steps.analyze.data.test_strategy}}\n\nAprueba para lanzar la implementación, o solicita cambios para re-ejecutar el análisis con tu feedback.', 'wiz.preset.ticketToPr.implementPrompt': 'Implementa el plan validado:\n---\n{{steps.analyze.data}}\n---\n\nSi una review previa dejó feedback a aplicar:\n{{state.last_review}}\n\n# Vas a cargar automáticamente:\n- `test-driven-development` (ritual red-green-refactor estricto)\n- `systematic-debugging` (root-cause en 4 fases si un test falla)\n- `verification-before-completion` (ningún claim sin evidencia)\n- `receiving-code-review` (técnica para aplicar feedback de review)\n\n# Procedimiento:\n1. Para CADA sub-tarea del plan: escribe primero los tests (red), después el código mínimo (green), después refactor.\n2. Lanza los tests a cada paso, verifica el output realmente.\n3. Si un test falla inesperadamente, aplica systematic-debugging (sin fixes al azar).\n4. Al final, output `[SIGNAL: CONTINUE]` para pasar a los tests de integración.', 'wiz.preset.ticketToPr.reviewPrompt': 'Revisa la implementación producida por `implement` y el resultado del paso `run_tests`.\n\nContexto:\n- Plan original: {{steps.analyze.summary}}\n- Tests output: {{steps.run_tests.data.stdout}}\n- Tests exit code: {{steps.run_tests.data.exit_code}}\n\n# Vas a cargar automáticamente las skills `requesting-code-review` y `verification-before-completion`.\n\n# Procedimiento:\n1. Verifica que la implementación cubre TODAS las sub-tareas del plan (no parcial).\n2. Verifica los tests: ¿prueban un comportamiento real, no solo mocks?\n3. YAGNI check: ¿la implementación añadió complejidad no pedida?\n4. Seguridad: ¿inyecciones, fugas de secretos, validación de entradas?\n5. Edge cases: null, empty, unicode, large input?\n\n# Veredicto:\nSi todo OK, termina tu respuesta con exactamente `[SIGNAL: APPROVED]`.\nSi no, escribe feedback accionable y exactamente al final (y SOLO al final):\n```\n---STATE:last_review=---\n[SIGNAL: NEEDS_CHANGES]\n```', - 'wiz.preset.ticketToPr.createPrPrompt': 'La implementación está validada y todos los tests pasan. Crea el PR.\n\nContexto:\n- Ticket: {{steps.fetch_issue.data}}\n- Resumen implementación: {{steps.implement.summary}}\n- Review aprobada: {{steps.review.summary}}\n\n# Vas a cargar automáticamente `finishing-a-development-branch` y `verification-before-completion`.\n\n# Procedimiento:\n1. Verifica una última vez que los tests pasan (comando exacto, output completo).\n2. Empuja la rama actual.\n3. Crea el PR vía `gh pr create` con:\n - Título coherente (referencia al ticket)\n - Body estructurado: Summary (3 bullets) + Test Plan (checklist)\n4. Output `state.pr_url=` y `state.pr_number=`.\n5. Termina con `[SIGNAL: PR_CREATED]`.', + 'wiz.preset.ticketToPr.createPrPrompt': 'La implementación está validada y todos los tests pasan. Crea el PR.\n\nContexto:\n- Ticket: {{steps.fetch_issue.data}}\n- Resumen implementación: {{steps.implement.summary}}\n- Review aprobada: {{steps.review.summary}}\n\n# Vas a cargar automáticamente `finishing-a-development-branch` y `verification-before-completion`.\n\n# Procedimiento:\n1. Verifica una última vez que los tests pasan (comando exacto, output completo).\n2. Empuja la rama actual.\n3. Crea el PR vía `gh pr create` con:\n - Título coherente (referencia al ticket)\n - Body estructurado: Summary (3 bullets) + Test Plan (checklist)\n4. Emite los markers Kronn para exponer la URL al siguiente paso — cada uno en su propia línea, exactamente este formato:\n ```\n ---STATE:pr_url=---\n ---STATE:pr_number=---\n ```\n (Importante: `state.pr_url=` SIN los `---STATE:...---` NO será leído por Kronn.)\n5. Termina con `[SIGNAL: PR_CREATED]`.', 'wiz.preset.ticketToPr.readyGateMessage': '## PR listo para merge\n\n**PR:** {{state.pr_url}}\n**Resumen:** {{steps.review.summary}}\n\nAprueba para finalizar (notificación de éxito), o solicita cambios para re-ejecutar `implement` con tu feedback (`state.last_review` será consumido por el agente).', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot completado: {{state.pr_url}} — {{steps.review.summary}}', 'wiz.preset.ticketToPr.rollbackBody': '❌ Ticket Autopilot falló en `{{failed_step.name}}` — detalle: {{failed_step.output}}', // 0.8.3 — Feasibility-Gated AutoPilot (auto-propuesto tras la auditoría IA) 'wiz.preset.feasibilityAutopilot.title': 'Big-ticket AutoPilot — feasibility-gated', 'wiz.preset.feasibilityAutopilot.desc': 'Para tickets grandes: un paso `triage` clasifica cada subtarea en clear / decided / mocked / blocked ANTES de cualquier código. El gate humano valida el plan, luego `implement` inserta marcadores `KRONN-(ASSUMED|MOCKED|TODO)` en cada libertad trazada. `run_tests` Exec lanza la suite real, `drift_check` Exec lista los marcadores en el PR final. Coste de tokens mínimo — solo triage / implement / pr_draft son Agent.', - 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Origen del ticket. JsonData fixture por defecto; el wizard cambia automáticamente a ApiCall cuando hay un plugin tracker (Jira/GitHub/GitLab) configurado en el proyecto. El paso siguiente lee `{{steps.fetch_issue.data.body}}`.', + 'wiz.preset.feasibilityAutopilot.fetchIssueDesc': 'Origen del ticket. JsonData fixture por defecto; el wizard cambia automáticamente a ApiCall cuando hay un plugin tracker (Jira/GitHub/GitLab) configurado en el proyecto. El paso siguiente lee `{{steps.fetch_issue.data}}` (objeto completo — el agente navega los campos según la fuente: `body` para el fixture, `fields.description` o `renderedFields.description` para Jira).', 'wiz.preset.feasibilityAutopilot.triageDesc': '[TRIAGE] Auditoría de viabilidad — clasifica cada subtarea en clear / decided / mocked / blocked. El agente NO escribe código aquí; produce un manifest JSON validado por schema (on_invalid=Fail). Cada entrada tiene un `id` estable que `implement` usará como decision_id en los marcadores.', - 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket body (from `fetch_issue`):\n---\n{{steps.fetch_issue.data.body}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', + 'wiz.preset.feasibilityAutopilot.triagePrompt': 'You are the TRIAGE step of a Feasibility-Gated AutoPilot run.\n\nTicket payload (from `fetch_issue` — full data object, navigate as needed for `body` / `description` / `fields.description` / `renderedFields.description` depending on source):\n---\n{{steps.fetch_issue.data}}\n---\n\nRead the project (current cwd is its worktree). Classify every sub-task into clear / decided / mocked / blocked per the schema. Emit the JSON manifest only — no markdown commentary outside the envelope.', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Revisión humana del manifest de triage antes del código. `gate_request_changes_target: triage` permite hasta 5 bucles.', 'wiz.preset.feasibilityAutopilot.gateMessage': 'Manifest de triage producido. Revisa las cuatro categorías abajo antes de aprobar:\n\n{{steps.triage.data}}\n\n- Approve: continúa a implementación tal cual.\n- Request changes: envía de vuelta al triage con tus notas (hasta 5 bucles).\n- Reject: abandona la ejecución.', 'wiz.preset.feasibilityAutopilot.implementDesc': 'Implementación restringida por el manifest validado. Cada entrada decided/mocked/blocked DEBE tener su marcador `KRONN-*` en el código. `[SIGNAL: BLOCKED ]` → Goto(triage) si el agente descubre una imposibilidad (cap 3 iteraciones).', diff --git a/frontend/src/lib/workflow-quick-start.ts b/frontend/src/lib/workflow-quick-start.ts new file mode 100644 index 00000000..52cbff91 --- /dev/null +++ b/frontend/src/lib/workflow-quick-start.ts @@ -0,0 +1,253 @@ +// 0.8.5 — unified quick-start catalogue for the workflow wizard. +// +// Pre-fix the wizard surfaced THREE independent "start from something" +// systems in two different places: +// 1. STARTER_TEMPLATES — plugin-aware buttons at the top of step 0 +// 2. backend WorkflowSuggestion[] — project-aware cards (toggle) on step 0 +// 3. WorkflowPreset[] (v0.7 presets) — primitives-mix cards buried in +// advanced → step 2 +// Three shapes, three apply paths, two locations. Discoverability was bad +// enough that users routinely missed the v07 presets entirely. +// +// This module normalises all three into a single `UnifiedQuickStart` +// shape and a single `applyQuickStart` dispatcher. Adapter functions +// map each source's native shape onto the unified one; nothing is +// invented (`audience` stays undefined for sources that don't have it, +// `applicable` is computed from the wizard's current context). + +import type { + WorkflowSuggestion, + WorkflowStep, +} from '../types/generated'; +import type { StarterTemplate } from './workflow-templates/chartbeat-top5'; +import type { WorkflowPreset } from './workflow-templates/v07-presets'; +import type { ApiPluginOption } from '../components/workflows/ApiCallStepCard'; + +/** Where this entry came from — drives the badge + the apply path. */ +export type QuickStartSource = 'starter' | 'project-suggestion' | 'preset'; + +/** Visual complexity tier. Maps from each source's native notion: + * - starter: forced 'intermediate' (real demos with 3-4 steps + plugin wiring) + * - project-suggestion: `complexity: "simple" | "advanced"` from the backend + * (we expand the binary into our tri-state by treating non-advanced as + * intermediate when stepsCount > 1, simple otherwise) + * - preset: derived from stepsCount + `onFailure` presence + * Used to sort the picker (simple → advanced) and render a badge. + */ +export type QuickStartComplexity = 'simple' | 'intermediate' | 'advanced'; + +/** Tagged-union payload — the picker hands this back to the wizard which + * knows how to apply each source's native shape. Keeping the original + * objects intact means the wizard's existing apply* handlers don't + * need to be rewritten, only re-routed. */ +export type QuickStartPayload = + | { kind: 'starter'; template: StarterTemplate } + | { kind: 'project-suggestion'; suggestion: WorkflowSuggestion } + | { kind: 'preset'; preset: WorkflowPreset }; + +export interface UnifiedQuickStart { + /** Stable id namespaced by source to avoid cross-source collisions. */ + id: string; + source: QuickStartSource; + title: string; + description: string; + complexity: QuickStartComplexity; + /** Persona / job (dev / data / devops / …). Optional — only filled + * on backend suggestions today. */ + audience?: string; + stepsCount: number; + /** Short chips shown under the title — primitives, plugin slug, etc. + * Bounded so a preset with 8 primitives doesn't bloat the row. */ + badges: string[]; + /** True when the entry's native preconditions are met by the wizard's + * current context. False = entry is still listed (user might want + * to discover it) but greyed-out + carries an explanatory hint. */ + applicable: boolean; + /** When `applicable === false`, a one-line reason ("requires the + * Chartbeat plugin", "no project selected"). */ + notApplicableReason?: string; + /** Backend suggestions carry a project-specific "why we recommend this" + * blurb — shown verbatim under the description when present. */ + reason?: string; + payload: QuickStartPayload; +} + +/** Translator function signature used to resolve preset i18n keys at + * catalogue-build time. Mirrors `useT()` from `I18nContext` so the + * builder stays pure (no React dep) — the caller passes `t` in. */ +type Translator = (key: string, ...args: (string | number)[]) => string; + +/** Compose a fresh array from all three sources. Caller passes whatever + * it has (the suggestions list may be empty if no project is bound). + * Output is sorted simple → advanced, then by stepsCount asc, then by + * title — stable and predictable so the same project always shows the + * same order. */ +export function buildQuickStartCatalogue(args: { + starters: StarterTemplate[]; + suggestions: WorkflowSuggestion[]; + presets: WorkflowPreset[]; + /** What the wizard currently knows about the project. Drives + * `applicable` flags. */ + ctx: { + projectSelected: boolean; + availableApiPlugins: ApiPluginOption[]; + }; + /** i18n translator — used to resolve preset `titleKey` / `descKey` + * into human-readable strings at catalogue-build time. Without it + * presets render their raw `id` ("auto-dev") and i18n key + * ("wiz.preset.autoDev.desc") in the picker UI — caught by the + * Playwright wizard-presets spec on 2026-05-18. */ + t: Translator; +}): UnifiedQuickStart[] { + const entries: UnifiedQuickStart[] = [ + ...args.starters.map(s => fromStarterTemplate(s, args.ctx)), + ...args.suggestions.map(s => fromSuggestion(s, args.ctx)), + ...args.presets.map(p => fromPreset(p, args.t)), + ]; + + return entries.sort(compareEntries); +} + +const COMPLEXITY_ORDER: Record = { + simple: 0, + intermediate: 1, + advanced: 2, +}; + +function compareEntries(a: UnifiedQuickStart, b: UnifiedQuickStart): number { + const c = COMPLEXITY_ORDER[a.complexity] - COMPLEXITY_ORDER[b.complexity]; + if (c !== 0) return c; + if (a.stepsCount !== b.stepsCount) return a.stepsCount - b.stepsCount; + return a.title.localeCompare(b.title, 'fr'); +} + +// ─── Adapter: StarterTemplate ────────────────────────────────────────── +// Starters are plugin-anchored demos (Chartbeat → résumé → Slack, …). +// Always advanced-mode wiring but conceptually entry-level UX ("clic → +// ça marche"). We classify as `intermediate` so they sit between simple +// presets and full advanced suggestions. +// +// Note: title_fr is used directly here. The picker layer doesn't know +// the current locale at construction time — we'd need to thread the +// `Lang` value through to pick `title_fr` vs `title_en`. For 0.8.5 we +// keep `title_fr` to match the previous wizard behaviour (it always +// rendered title_fr). Localising the catalogue properly is a follow-up. +function fromStarterTemplate( + t: StarterTemplate, + ctx: { availableApiPlugins: ApiPluginOption[] }, +): UnifiedQuickStart { + const pluginAvailable = ctx.availableApiPlugins.some( + p => p.server.id === t.primary_plugin_slug, + ); + return { + id: `starter:${t.id}`, + source: 'starter', + title: t.title_fr, + description: t.description_fr, + complexity: 'intermediate', + stepsCount: t.steps.length, + badges: [t.primary_plugin_slug], + applicable: pluginAvailable, + notApplicableReason: pluginAvailable + ? undefined + : `requires plugin: ${t.primary_plugin_slug}`, + payload: { kind: 'starter', template: t }, + }; +} + +// ─── Adapter: WorkflowSuggestion (backend, project-aware) ────────────── +// The backend computes these against the project's actual configured +// MCPs/APIs and emits a `complexity: "simple" | "advanced"` string plus +// `required_mcps`. We expand to our tri-state and feed `required_mcps` +// into the badge row so the user sees at a glance what each entry +// touches. +function fromSuggestion( + s: WorkflowSuggestion, + _ctx: { projectSelected: boolean }, +): UnifiedQuickStart { + // Backend uses a two-value enum; intermediate kicks in for 2–3 step + // suggestions that the backend tagged "simple" — they're functionally + // a bit more than a one-shot agent call. + let complexity: QuickStartComplexity = 'simple'; + if (s.complexity === 'advanced' || s.steps.length >= 4) { + complexity = 'advanced'; + } else if (s.steps.length >= 2) { + complexity = 'intermediate'; + } + return { + id: `suggestion:${s.id}`, + source: 'project-suggestion', + title: s.title, + description: s.description, + complexity, + audience: s.audience, + stepsCount: s.steps.length, + badges: s.required_mcps.slice(0, 4), // cap to keep the row compact + applicable: true, // backend only emits relevant suggestions + reason: s.reason, + payload: { kind: 'project-suggestion', suggestion: s }, + }; +} + +// ─── Adapter: WorkflowPreset (v0.7 primitives mix) ───────────────────── +// Presets are static and project-agnostic — always applicable. We map +// complexity from stepsCount + onFailure presence: a preset with a +// rollback chain is advanced; 1-2 steps is simple; 3+ steps is +// intermediate. The icon prefix is preserved in the rendered title so +// downstream tests / users can disambiguate presets that share a +// keyword (e.g. 🎫 Ticket Autopilot vs 🎯 Big-ticket AutoPilot). +function fromPreset(p: WorkflowPreset, t: Translator): UnifiedQuickStart { + let complexity: QuickStartComplexity; + if (p.onFailure && p.onFailure.length > 0) { + complexity = 'advanced'; + } else if (p.steps.length >= 3) { + complexity = 'intermediate'; + } else { + complexity = 'simple'; + } + return { + id: `preset:${p.id}`, + source: 'preset', + title: `${p.icon} ${t(p.titleKey)}`, + description: t(p.descKey), + complexity, + stepsCount: p.steps.length, + badges: p.primitives.slice(0, 5), + applicable: true, + payload: { kind: 'preset', preset: p }, + }; +} + +/** Convenience: same as `buildQuickStartCatalogue` but also filters by + * a free-text query (case-insensitive, matches title + description + * + badges). Useful for the picker's search input. + */ +export function filterQuickStart( + list: UnifiedQuickStart[], + query: string, +): UnifiedQuickStart[] { + const q = query.trim().toLowerCase(); + if (!q) return list; + return list.filter(e => { + if (e.title.toLowerCase().includes(q)) return true; + if (e.description.toLowerCase().includes(q)) return true; + if (e.badges.some(b => b.toLowerCase().includes(q))) return true; + if (e.audience && e.audience.toLowerCase().includes(q)) return true; + return false; + }); +} + +/** Re-export for the test file to assert on the comparator's stability + * without re-implementing it. Not part of the public API. */ +export const __testing = { compareEntries, COMPLEXITY_ORDER }; + +/** Steps preview helper used by the picker's tooltip / detail panel — + * shows the first N step names as a hint of what the entry does + * before the user commits. */ +export function quickStartStepsPreview(entry: UnifiedQuickStart, max = 4): string[] { + const steps: WorkflowStep[] = + entry.payload.kind === 'starter' ? entry.payload.template.steps : + entry.payload.kind === 'project-suggestion' ? entry.payload.suggestion.steps : + entry.payload.preset.steps; + return steps.slice(0, max).map(s => s.name); +} diff --git a/frontend/src/pages/DiscussionsPage.css b/frontend/src/pages/DiscussionsPage.css index b8ae4dd8..2239c347 100644 --- a/frontend/src/pages/DiscussionsPage.css +++ b/frontend/src/pages/DiscussionsPage.css @@ -856,6 +856,34 @@ cursor: help; } +/* 0.8.5 — short disc-id pill in ChatHeader. Click → copy full UUID + * to clipboard. Neutral ghost colour so it doesn't compete with the + * introspection pill or title — but mono font for the id readability. */ +.disc-id-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-family: var(--kr-font-mono, ui-monospace, monospace); + font-weight: 500; + background: var(--kr-bg-subtle); + color: var(--kr-text-ghost); + border: 1px solid var(--kr-border-ghost); + flex-shrink: 0; + cursor: pointer; + transition: color 120ms ease, border-color 120ms ease, background 120ms ease; +} +.disc-id-pill:hover { + color: var(--kr-text-primary); + border-color: var(--kr-accent-border); + background: var(--kr-accent-bg); +} +.disc-id-pill:focus-visible { + outline: 2px solid var(--kr-accent); + outline-offset: 1px; +} + /* Inline notice rendered under a popover section when the active * agent doesn't support a feature surfaced by that section (e.g. the * summary-strategy section warns Vibe / Ollama users that the diff --git a/frontend/src/pages/WorkflowsPage.css b/frontend/src/pages/WorkflowsPage.css index 9341c3ac..b771ffe8 100644 --- a/frontend/src/pages/WorkflowsPage.css +++ b/frontend/src/pages/WorkflowsPage.css @@ -4033,3 +4033,275 @@ .qp-compare-toggle-all:hover { color: var(--kr-text-primary); } + +/* ─── 0.8.5 — WorkflowQuickStartPicker ───────────────────────────── + Unified entry point at top of wizard step 0. Collapsed = single + chip; expanded = panel with search + filters + list of cards. */ + +.wf-quickstart-toggle { + display: inline-flex; + align-items: center; + gap: var(--kr-sp-2); + padding: var(--kr-sp-2) var(--kr-sp-3); + background: var(--kr-accent-bg); + border: 1px solid var(--kr-accent-border); + border-radius: var(--kr-radius-md); + color: var(--kr-text-primary); + font-size: var(--kr-fs-sm); + cursor: pointer; + margin-bottom: var(--kr-sp-4); + transition: background 120ms ease, border-color 120ms ease; +} +.wf-quickstart-toggle:hover:not(:disabled) { + background: var(--kr-accent-bg-hover); + border-color: var(--kr-accent); +} +.wf-quickstart-toggle:disabled { + opacity: 0.45; + cursor: not-allowed; + background: var(--kr-bg-subtle); + border-color: var(--kr-border-ghost); + color: var(--kr-text-ghost); +} + +.wf-quickstart-panel { + background: var(--kr-bg-surface); + border: 1px solid var(--kr-accent-border); + border-radius: var(--kr-radius-md); + padding: var(--kr-sp-3) var(--kr-sp-4); + margin-bottom: var(--kr-sp-6); +} + +.wf-quickstart-header { + display: flex; + align-items: center; + gap: var(--kr-sp-2); + margin-bottom: var(--kr-sp-3); +} +.wf-quickstart-title { + font-size: var(--kr-fs-sm); + font-weight: 600; + color: var(--kr-text-primary); +} +.wf-quickstart-hint { + font-size: var(--kr-fs-xs); + color: var(--kr-text-ghost); +} + +.wf-quickstart-controls { + display: flex; + flex-wrap: wrap; + gap: var(--kr-sp-3); + align-items: center; + margin-bottom: var(--kr-sp-3); + padding-bottom: var(--kr-sp-3); + border-bottom: 1px solid var(--kr-border-ghost); +} +.wf-quickstart-search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 200px; +} +.wf-quickstart-search-icon { + position: absolute; + left: var(--kr-sp-2); + color: var(--kr-text-ghost); + pointer-events: none; +} +.wf-quickstart-search-input { + padding-left: 24px !important; +} + +.wf-quickstart-filters { + display: flex; + align-items: center; + gap: var(--kr-sp-1); + flex-wrap: wrap; +} +.wf-quickstart-filter-chip { + font-size: var(--kr-fs-2xs); + padding: 2px var(--kr-sp-2); + border-radius: var(--kr-radius-pill); + border: 1px solid var(--kr-border); + background: transparent; + color: var(--kr-text-tertiary); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} +.wf-quickstart-filter-chip[data-active="true"] { + background: var(--kr-accent-bg-strong); + border-color: var(--kr-accent); + color: var(--kr-text-primary); +} +.wf-quickstart-filter-chip:hover { + border-color: var(--kr-accent); + color: var(--kr-text-primary); +} + +.wf-quickstart-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--kr-sp-2); + max-height: 480px; + overflow-y: auto; +} + +.wf-quickstart-empty { + padding: var(--kr-sp-4); + text-align: center; + color: var(--kr-text-ghost); + font-size: var(--kr-fs-xs); + font-style: italic; +} + +.wf-quickstart-row { + padding: var(--kr-sp-3); + background: var(--kr-bg-raised); + border: 1px solid var(--kr-border); + border-radius: var(--kr-radius-sm); + transition: border-color 120ms ease, background 120ms ease; +} +.wf-quickstart-row:hover { + border-color: var(--kr-accent-border); + background: var(--kr-bg-elevated); +} +.wf-quickstart-row[data-applicable="false"] { + opacity: 0.55; +} +.wf-quickstart-row[data-applicable="false"]:hover { + opacity: 0.85; +} + +.wf-quickstart-row-top { + display: flex; + align-items: flex-start; + gap: var(--kr-sp-2); + margin-bottom: var(--kr-sp-1); +} +.wf-quickstart-row-title { + flex: 1; + font-weight: 600; + font-size: var(--kr-fs-sm); + color: var(--kr-text-primary); +} +.wf-quickstart-row-badges { + display: flex; + gap: var(--kr-sp-1); + flex-wrap: wrap; + align-items: center; +} + +.wf-quickstart-complexity-badge, +.wf-quickstart-source-badge, +.wf-quickstart-audience-badge { + font-size: 10px; + padding: 1px var(--kr-sp-2); + border-radius: var(--kr-radius-pill); + border: 1px solid var(--kr-border); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--kr-text-tertiary); + white-space: nowrap; +} +.wf-quickstart-complexity-badge[data-complexity="simple"] { + border-color: rgba(120, 200, 80, 0.4); + color: rgba(150, 220, 110, 0.95); +} +.wf-quickstart-complexity-badge[data-complexity="intermediate"] { + border-color: rgba(200, 160, 60, 0.4); + color: rgba(230, 190, 90, 0.95); +} +.wf-quickstart-complexity-badge[data-complexity="advanced"] { + border-color: rgba(220, 100, 100, 0.4); + color: rgba(240, 130, 130, 0.95); +} +.wf-quickstart-source-badge[data-source="preset"] { + border-color: var(--kr-accent-border); + color: var(--kr-accent-text); +} +.wf-quickstart-source-badge[data-source="starter"] { + border-color: rgba(100, 180, 220, 0.4); + color: rgba(140, 200, 240, 0.95); +} +.wf-quickstart-source-badge[data-source="project-suggestion"] { + border-color: rgba(200, 120, 220, 0.4); + color: rgba(220, 150, 240, 0.95); +} + +.wf-quickstart-row-desc { + font-size: var(--kr-fs-xs); + color: var(--kr-text-secondary); + margin: 0 0 var(--kr-sp-2); + line-height: 1.4; +} +.wf-quickstart-row-reason { + font-size: var(--kr-fs-2xs); + color: var(--kr-text-ghost); + margin: 0 0 var(--kr-sp-2); + font-style: italic; +} + +.wf-quickstart-row-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--kr-sp-2); + margin-bottom: var(--kr-sp-2); +} +.wf-quickstart-steps-count { + font-size: var(--kr-fs-2xs); + color: var(--kr-text-tertiary); + font-weight: 500; +} +.wf-quickstart-row-tag { + font-size: 10px; + padding: 1px var(--kr-sp-2); + border-radius: var(--kr-radius-sm); + background: var(--kr-bg-subtle); + color: var(--kr-text-tertiary); + border: 1px solid var(--kr-border-ghost); +} +.wf-quickstart-steps-preview { + font-size: var(--kr-fs-2xs); + color: var(--kr-text-ghost); + font-family: var(--kr-font-mono, monospace); + margin-left: auto; +} + +.wf-quickstart-row-warning { + font-size: var(--kr-fs-2xs); + color: rgba(240, 130, 130, 0.95); + margin: 0 0 var(--kr-sp-2); +} + +.wf-quickstart-row-actions { + display: flex; + justify-content: flex-end; +} +.wf-quickstart-apply-btn { + padding: var(--kr-sp-1) var(--kr-sp-3); + background: var(--kr-accent); + color: var(--kr-text-on-accent); + border: none; + border-radius: var(--kr-radius-sm); + font-size: var(--kr-fs-xs); + font-weight: 600; + cursor: pointer; + transition: opacity 120ms ease, filter 120ms ease; +} +.wf-quickstart-apply-btn:hover:not(:disabled) { + filter: brightness(1.1); +} +.wf-quickstart-apply-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.wf-quickstart-loading { + padding: var(--kr-sp-2); + text-align: center; +} diff --git a/frontend/src/pages/WorkflowsPage.tsx b/frontend/src/pages/WorkflowsPage.tsx index 88246621..b9bb0af1 100644 --- a/frontend/src/pages/WorkflowsPage.tsx +++ b/frontend/src/pages/WorkflowsPage.tsx @@ -2166,18 +2166,21 @@ export function WorkflowsPage({ projects, installedAgentTypes, agentAccess, conf } : prev); return; } - setLaunchingWorkflow(prev => prev ? { ...prev, submitting: true, error: null } : prev); - try { - await fireTrigger(launchingWorkflow.workflow.id, launchingWorkflow.values); - setLaunchingWorkflow(null); - } catch (e) { + // 0.8.5 — close the modal IMMEDIATELY after validation + // passes. Pre-fix the modal awaited `fireTrigger(...)` + // which only resolves when the SSE stream completes + // (i.e. the whole run is done) — so the launch box + // stayed open for the entire workflow duration, often + // tens of minutes. The live progress view (`liveRun`) + // takes over rendering once `fireTrigger` fires. + const wfId = launchingWorkflow.workflow.id; + const vals = launchingWorkflow.values; + setLaunchingWorkflow(null); + fireTrigger(wfId, vals).catch(e => { + // Failure path: the live-run pane surfaces the error; + // we just log here so the warning still hits devtools. console.warn('Launch failed:', e); - setLaunchingWorkflow(prev => prev ? { - ...prev, - submitting: false, - error: String(e), - } : prev); - } + }); }} > {launchingWorkflow.submitting diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 360e37ae..9dc042e2 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/App.test.tsx","./src/__tests__/ErrorBoundary.test.tsx","./src/components/ActiveAuditsPopover.tsx","./src/components/AgentQuestionForm.tsx","./src/components/AiDocViewer.tsx","./src/components/AuditRecapPanel.tsx","./src/components/BackendStatus.tsx","./src/components/BriefingForm.tsx","./src/components/ChatHeader.tsx","./src/components/ChatInput.tsx","./src/components/CustomApiAiHelper.tsx","./src/components/DiscussionSidebar.tsx","./src/components/DocDataExport.tsx","./src/components/DocPreview.tsx","./src/components/ErrorBoundary.tsx","./src/components/GitPanel.tsx","./src/components/HostSyncChip.tsx","./src/components/HostSyncPreview.tsx","./src/components/MatrixText.tsx","./src/components/MermaidDiagram.tsx","./src/components/MessageBubble.tsx","./src/components/NewDiscussionForm.tsx","./src/components/ProfileTooltip.tsx","./src/components/ProjectCard.tsx","./src/components/ProjectLinkedRepos.tsx","./src/components/ProjectList.tsx","./src/components/ProjectSkills.tsx","./src/components/QPCardMetricsChip.tsx","./src/components/QPHistoryDrawer.tsx","./src/components/SubAuditModal.tsx","./src/components/SwipeableDiscItem.tsx","./src/components/TestModeBanner.tsx","./src/components/TestModeModal.tsx","./src/components/ThemeEffects.tsx","./src/components/UpdateBanner.tsx","./src/components/UserContextEditor.tsx","./src/components/__tests__/ActiveAuditsPopover.test.tsx","./src/components/__tests__/AgentQuestionForm.test.tsx","./src/components/__tests__/AuditRecapPanel.test.tsx","./src/components/__tests__/BackendStatus.test.tsx","./src/components/__tests__/BriefingForm.test.tsx","./src/components/__tests__/ChatHeader.pendingFiles.test.tsx","./src/components/__tests__/ChatHeader.profileEditor.test.tsx","./src/components/__tests__/ChatInput.draft.test.tsx","./src/components/__tests__/ChatInput.qpChain.test.tsx","./src/components/__tests__/CustomApiAiHelper.test.tsx","./src/components/__tests__/DiscussionSidebar.contact-add.test.tsx","./src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx","./src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx","./src/components/__tests__/DocDataExport.test.tsx","./src/components/__tests__/DocPreview.test.tsx","./src/components/__tests__/GitPanel.test.tsx","./src/components/__tests__/HostSyncPreview.test.tsx","./src/components/__tests__/MarkdownFence.test.tsx","./src/components/__tests__/MermaidDiagram.test.tsx","./src/components/__tests__/MessageBubble.emoji.test.tsx","./src/components/__tests__/MessageBubble.kronnTool.test.tsx","./src/components/__tests__/MessageBubble.seedToggle.test.tsx","./src/components/__tests__/MessageBubble.validationCta.test.tsx","./src/components/__tests__/NewDiscussionForm.test.tsx","./src/components/__tests__/ProfileTooltip.test.tsx","./src/components/__tests__/ProjectCard.audit-resume.test.tsx","./src/components/__tests__/ProjectCard.header-a11y.test.tsx","./src/components/__tests__/ProjectCard.migration.test.tsx","./src/components/__tests__/QPHistoryDrawer.test.tsx","./src/components/__tests__/SmokeTests.test.tsx","./src/components/__tests__/SubAuditModal.test.tsx","./src/components/__tests__/TestModeBanner.test.tsx","./src/components/__tests__/TestModeModal.test.tsx","./src/components/__tests__/ThemeEffects.test.tsx","./src/components/__tests__/UpdateBanner.test.tsx","./src/components/__tests__/UserContextEditor.test.tsx","./src/components/settings/AgentsSection.tsx","./src/components/settings/CompressionSection.tsx","./src/components/settings/DebugSection.tsx","./src/components/settings/HostDiscoverySection.tsx","./src/components/settings/IdentitySection.tsx","./src/components/settings/OllamaCard.tsx","./src/components/settings/ProfilesSection.tsx","./src/components/settings/UsageSection.tsx","./src/components/settings/__tests__/CompressionSection.test.tsx","./src/components/settings/__tests__/HostDiscoverySection.test.tsx","./src/components/tour/TourHelpButton.tsx","./src/components/tour/TourOverlay.tsx","./src/components/tour/TourProvider.tsx","./src/components/tour/tourSteps.ts","./src/components/tour/useTourPositioning.ts","./src/components/tour/__tests__/Tour.test.tsx","./src/components/workflows/ActiveRunsPopover.tsx","./src/components/workflows/ApiCallAiHelper.tsx","./src/components/workflows/ApiCallStepCard.tsx","./src/components/workflows/ExecutionLimitsCard.tsx","./src/components/workflows/ImportDropzone.tsx","./src/components/workflows/QuickApiForm.tsx","./src/components/workflows/QuickPromptForm.tsx","./src/components/workflows/RunDetail.tsx","./src/components/workflows/WorkflowDetail.tsx","./src/components/workflows/WorkflowWizard.tsx","./src/components/workflows/apiCallAuth.ts","./src/components/workflows/apiCallPlaceholders.ts","./src/components/workflows/apiCallPluginTips.ts","./src/components/workflows/apiCallSuggestions.ts","./src/components/workflows/parseBatchQAItems.ts","./src/components/workflows/__tests__/ActiveRunsPopover.test.tsx","./src/components/workflows/__tests__/ApiCallAiHelper.test.tsx","./src/components/workflows/__tests__/ApiCallStepCard.test.tsx","./src/components/workflows/__tests__/BatchItemsList.test.tsx","./src/components/workflows/__tests__/ExecutionLimitsCard.test.tsx","./src/components/workflows/__tests__/LiveFinishedBanner.test.tsx","./src/components/workflows/__tests__/QuickPromptForm.bindings.test.tsx","./src/components/workflows/__tests__/RunDetail.test.tsx","./src/components/workflows/__tests__/SmokeTests.test.tsx","./src/components/workflows/__tests__/apiCallAuth.test.ts","./src/components/workflows/__tests__/apiCallPlaceholders.test.ts","./src/components/workflows/__tests__/apiCallPluginTips.test.ts","./src/components/workflows/__tests__/apiCallSuggestions.test.ts","./src/components/workflows/__tests__/parseBatchQAItems.test.ts","./src/hooks/useApi.ts","./src/hooks/useAsyncGuard.ts","./src/hooks/useKonamiCode.ts","./src/hooks/useMatrixDecode.ts","./src/hooks/useMediaQuery.ts","./src/hooks/useQpChain.ts","./src/hooks/useRafBatchedStream.ts","./src/hooks/useToast.tsx","./src/hooks/useWebSocket.ts","./src/hooks/__tests__/useApi.test.ts","./src/hooks/__tests__/useAsyncGuard.test.tsx","./src/hooks/__tests__/useKonamiCode.test.tsx","./src/hooks/__tests__/useMatrixDecode.test.tsx","./src/hooks/__tests__/useMediaQuery.test.ts","./src/hooks/__tests__/useQpChain.test.ts","./src/hooks/__tests__/useRafBatchedStream.test.ts","./src/hooks/__tests__/useToast.test.tsx","./src/hooks/__tests__/useWebSocket.test.ts","./src/lib/I18nContext.tsx","./src/lib/ThemeContext.tsx","./src/lib/agent-question-parse.ts","./src/lib/api.ts","./src/lib/audit-resume.ts","./src/lib/autoTriggers.ts","./src/lib/bug-report.ts","./src/lib/chat-drafts.ts","./src/lib/constants.ts","./src/lib/diff-syntax.ts","./src/lib/downloadBlob.ts","./src/lib/emoji-autocomplete.ts","./src/lib/extractLikelyOutput.ts","./src/lib/gravatar.ts","./src/lib/i18n.ts","./src/lib/qp-improver-banner.ts","./src/lib/relativeTime.ts","./src/lib/scanUndeclaredVars.ts","./src/lib/stream-flush.ts","./src/lib/stream-watchdog.ts","./src/lib/stt-engine.ts","./src/lib/stt-models.ts","./src/lib/stt-worker.ts","./src/lib/tts-engine.ts","./src/lib/tts-models.ts","./src/lib/tts-utils.ts","./src/lib/tts-worker.ts","./src/lib/userError.ts","./src/lib/version.ts","./src/lib/workflowVariables.ts","./src/lib/__tests__/I18nContext.test.tsx","./src/lib/__tests__/ThemeContext.test.tsx","./src/lib/__tests__/access-warnings.test.ts","./src/lib/__tests__/agent-question-parse.test.ts","./src/lib/__tests__/api.fullAuditStream.legacyDocs.test.ts","./src/lib/__tests__/api.test.ts","./src/lib/__tests__/audit-resume.test.ts","./src/lib/__tests__/autoTriggers.test.ts","./src/lib/__tests__/batch-input-parse.test.ts","./src/lib/__tests__/batch-sidebar-grouping.test.ts","./src/lib/__tests__/bootstrap-signals.test.ts","./src/lib/__tests__/bug-report.test.ts","./src/lib/__tests__/chat-drafts.test.ts","./src/lib/__tests__/constants.test.ts","./src/lib/__tests__/cron-parsing.test.ts","./src/lib/__tests__/diff-syntax.test.ts","./src/lib/__tests__/downloadBlob.test.ts","./src/lib/__tests__/emoji-autocomplete.test.ts","./src/lib/__tests__/extractLikelyOutput.test.ts","./src/lib/__tests__/gravatar.test.ts","./src/lib/__tests__/i18n-parity.test.ts","./src/lib/__tests__/i18n.test.ts","./src/lib/__tests__/mcp-category-filter.test.ts","./src/lib/__tests__/qp-improver-banner.test.ts","./src/lib/__tests__/qp-improver-signal.test.ts","./src/lib/__tests__/quick-prompt-render.test.ts","./src/lib/__tests__/regression.test.ts","./src/lib/__tests__/relativeTime.test.ts","./src/lib/__tests__/scanUndeclaredVars.test.ts","./src/lib/__tests__/stream-flush.test.ts","./src/lib/__tests__/stream-watchdog.test.ts","./src/lib/__tests__/streaming.test.ts","./src/lib/__tests__/stt-engine.test.ts","./src/lib/__tests__/stt-models.test.ts","./src/lib/__tests__/tts-models.test.ts","./src/lib/__tests__/tts-utils.test.ts","./src/lib/__tests__/types.test.ts","./src/lib/__tests__/userError.test.ts","./src/lib/__tests__/version.test.ts","./src/lib/__tests__/workflowVariables.test.ts","./src/lib/workflow-templates/chartbeat-top5.ts","./src/lib/workflow-templates/v07-presets.ts","./src/lib/workflow-templates/__tests__/chartbeat-top5.test.ts","./src/lib/workflow-templates/__tests__/v07-presets.test.ts","./src/pages/Dashboard.tsx","./src/pages/DiscussionsPage.tsx","./src/pages/McpPage.tsx","./src/pages/SettingsPage.tsx","./src/pages/SetupWizard.tsx","./src/pages/WorkflowsPage.tsx","./src/pages/__tests__/Dashboard.bootstrap-mcp.test.tsx","./src/pages/__tests__/Dashboard.test.tsx","./src/pages/__tests__/DiscussionsPage.test.tsx","./src/pages/__tests__/McpPage.test.tsx","./src/pages/__tests__/SettingsPage.test.tsx","./src/pages/__tests__/SetupWizard.test.tsx","./src/pages/__tests__/WorkflowsPage.qp-launch.test.tsx","./src/pages/__tests__/WorkflowsPage.requiredVars.test.tsx","./src/pages/__tests__/WorkflowsPage.test.tsx","./src/pages/__tests__/appendLiveBuffer.test.ts","./src/test/apiMock.complete.test.ts","./src/test/apiMock.ts","./src/test/setup.ts","./src/types/AgentConfig.ts","./src/types/AgentDetection.ts","./src/types/AgentProfile.ts","./src/types/AgentProjectUsage.ts","./src/types/AgentSettings.ts","./src/types/AgentType.ts","./src/types/AgentUsageSummary.ts","./src/types/AgentsConfig.ts","./src/types/AiAuditStatus.ts","./src/types/AiConfigStatus.ts","./src/types/AiConfigType.ts","./src/types/AiFileContent.ts","./src/types/AiFileNode.ts","./src/types/AiSearchResult.ts","./src/types/ApiAuthKind.ts","./src/types/ApiConfigKey.ts","./src/types/ApiEndpoint.ts","./src/types/ApiKey.ts","./src/types/ApiKeyDisplay.ts","./src/types/ApiKeysResponse.ts","./src/types/ApiSpec.ts","./src/types/AppConfig.ts","./src/types/AuditFileInfo.ts","./src/types/AuditInfo.ts","./src/types/AuditKind.ts","./src/types/AuditTodo.ts","./src/types/BootstrapProjectRequest.ts","./src/types/BootstrapProjectResponse.ts","./src/types/CloneProjectRequest.ts","./src/types/CloneProjectResponse.ts","./src/types/ConditionAction.ts","./src/types/Contact.ts","./src/types/CreateDirectiveRequest.ts","./src/types/CreateDiscussionRequest.ts","./src/types/CreateMcpConfigRequest.ts","./src/types/CreateProfileRequest.ts","./src/types/CreateSkillRequest.ts","./src/types/CreateWorkflowRequest.ts","./src/types/DailyUsage.ts","./src/types/DbExport.ts","./src/types/DbInfo.ts","./src/types/DetectedIp.ts","./src/types/DetectedRepo.ts","./src/types/Directive.ts","./src/types/DirectiveCategory.ts","./src/types/DiscoverKeysResponse.ts","./src/types/DiscoverReposRequest.ts","./src/types/DiscoverReposResponse.ts","./src/types/DiscoveredKey.ts","./src/types/Discussion.ts","./src/types/DiscussionMessage.ts","./src/types/DriftCheckResponse.ts","./src/types/DriftSection.ts","./src/types/ExecResponse.ts","./src/types/GitBranchRequest.ts","./src/types/GitBranchResponse.ts","./src/types/GitCommitRequest.ts","./src/types/GitCommitResponse.ts","./src/types/GitDiffQuery.ts","./src/types/GitDiffResponse.ts","./src/types/GitFileStatus.ts","./src/types/GitPushResponse.ts","./src/types/GitStatusResponse.ts","./src/types/HostSyncMode.ts","./src/types/ImportWorkflowRequest.ts","./src/types/LaunchAuditRequest.ts","./src/types/LinkMcpConfigRequest.ts","./src/types/McpConfig.ts","./src/types/McpConfigDisplay.ts","./src/types/McpContextEntry.ts","./src/types/McpDefinition.ts","./src/types/McpEnvEntry.ts","./src/types/McpIncompatibility.ts","./src/types/McpIncompleteConfig.ts","./src/types/McpOverview.ts","./src/types/McpServer.ts","./src/types/McpSource.ts","./src/types/McpTransport.ts","./src/types/MessageRole.ts","./src/types/ModelTier.ts","./src/types/ModelTierConfig.ts","./src/types/ModelTiersConfig.ts","./src/types/NetworkInfo.ts","./src/types/OAuth2ExtraHeader.ts","./src/types/OrchestrationRequest.ts","./src/types/PartialAuditRequest.ts","./src/types/ProfileCategory.ts","./src/types/Project.ts","./src/types/ProjectUsage.ts","./src/types/ProviderUsage.ts","./src/types/RemoteRepo.ts","./src/types/RepoSource.ts","./src/types/RetryConfig.ts","./src/types/RunStatus.ts","./src/types/SaveApiKeyRequest.ts","./src/types/ScanConfig.ts","./src/types/SendMessageRequest.ts","./src/types/ServerConfig.ts","./src/types/ServerConfigPublic.ts","./src/types/SetAgentAccessRequest.ts","./src/types/SetBriefingRequest.ts","./src/types/SetScanPathsRequest.ts","./src/types/SetupStatus.ts","./src/types/SetupStep.ts","./src/types/Skill.ts","./src/types/SkillCategory.ts","./src/types/StartBriefingResponse.ts","./src/types/StepConditionRule.ts","./src/types/StepMode.ts","./src/types/StepResult.ts","./src/types/StepType.ts","./src/types/TechDebtItem.ts","./src/types/TokenOverride.ts","./src/types/TokenUsageSummary.ts","./src/types/TokensConfig.ts","./src/types/TrackerSourceConfig.ts","./src/types/UpdateDiscussionRequest.ts","./src/types/UpdateMcpConfigRequest.ts","./src/types/UpdateMcpContextRequest.ts","./src/types/UpdateWorkflowRequest.ts","./src/types/Workflow.ts","./src/types/WorkflowAction.ts","./src/types/WorkflowRun.ts","./src/types/WorkflowRunSummary.ts","./src/types/WorkflowSafety.ts","./src/types/WorkflowStep.ts","./src/types/WorkflowSummary.ts","./src/types/WorkflowTrigger.ts","./src/types/WorkspaceConfig.ts","./src/types/WorkspaceHooks.ts","./src/types/WsMessage.ts","./src/types/extensions.d.ts","./src/types/generated.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/App.test.tsx","./src/__tests__/ErrorBoundary.test.tsx","./src/components/ActiveAuditsPopover.tsx","./src/components/AgentQuestionForm.tsx","./src/components/AiDocViewer.tsx","./src/components/AuditRecapPanel.tsx","./src/components/BackendStatus.tsx","./src/components/BriefingForm.tsx","./src/components/ChatHeader.tsx","./src/components/ChatInput.tsx","./src/components/CustomApiAiHelper.tsx","./src/components/DiscussionSidebar.tsx","./src/components/DocDataExport.tsx","./src/components/DocPreview.tsx","./src/components/ErrorBoundary.tsx","./src/components/GitPanel.tsx","./src/components/HostSyncChip.tsx","./src/components/HostSyncPreview.tsx","./src/components/MatrixText.tsx","./src/components/MermaidDiagram.tsx","./src/components/MessageBubble.tsx","./src/components/NewDiscussionForm.tsx","./src/components/ProfileTooltip.tsx","./src/components/ProjectCard.tsx","./src/components/ProjectLinkedRepos.tsx","./src/components/ProjectList.tsx","./src/components/ProjectSkills.tsx","./src/components/QPCardMetricsChip.tsx","./src/components/QPHistoryDrawer.tsx","./src/components/SubAuditModal.tsx","./src/components/SwipeableDiscItem.tsx","./src/components/TestModeBanner.tsx","./src/components/TestModeModal.tsx","./src/components/ThemeEffects.tsx","./src/components/UpdateBanner.tsx","./src/components/UserContextEditor.tsx","./src/components/__tests__/ActiveAuditsPopover.test.tsx","./src/components/__tests__/AgentQuestionForm.test.tsx","./src/components/__tests__/AuditRecapPanel.test.tsx","./src/components/__tests__/BackendStatus.test.tsx","./src/components/__tests__/BriefingForm.test.tsx","./src/components/__tests__/ChatHeader.pendingFiles.test.tsx","./src/components/__tests__/ChatHeader.profileEditor.test.tsx","./src/components/__tests__/ChatInput.draft.test.tsx","./src/components/__tests__/ChatInput.qpChain.test.tsx","./src/components/__tests__/CustomApiAiHelper.test.tsx","./src/components/__tests__/DiscussionSidebar.contact-add.test.tsx","./src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx","./src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx","./src/components/__tests__/DocDataExport.test.tsx","./src/components/__tests__/DocPreview.test.tsx","./src/components/__tests__/GitPanel.test.tsx","./src/components/__tests__/HostSyncPreview.test.tsx","./src/components/__tests__/MarkdownFence.test.tsx","./src/components/__tests__/MermaidDiagram.test.tsx","./src/components/__tests__/MessageBubble.emoji.test.tsx","./src/components/__tests__/MessageBubble.kronnTool.test.tsx","./src/components/__tests__/MessageBubble.seedToggle.test.tsx","./src/components/__tests__/MessageBubble.validationCta.test.tsx","./src/components/__tests__/NewDiscussionForm.test.tsx","./src/components/__tests__/ProfileTooltip.test.tsx","./src/components/__tests__/ProjectCard.audit-resume.test.tsx","./src/components/__tests__/ProjectCard.header-a11y.test.tsx","./src/components/__tests__/ProjectCard.migration.test.tsx","./src/components/__tests__/QPHistoryDrawer.test.tsx","./src/components/__tests__/SmokeTests.test.tsx","./src/components/__tests__/SubAuditModal.test.tsx","./src/components/__tests__/TestModeBanner.test.tsx","./src/components/__tests__/TestModeModal.test.tsx","./src/components/__tests__/ThemeEffects.test.tsx","./src/components/__tests__/UpdateBanner.test.tsx","./src/components/__tests__/UserContextEditor.test.tsx","./src/components/settings/AgentsSection.tsx","./src/components/settings/CompressionSection.tsx","./src/components/settings/DebugSection.tsx","./src/components/settings/HostDiscoverySection.tsx","./src/components/settings/IdentitySection.tsx","./src/components/settings/OllamaCard.tsx","./src/components/settings/ProfilesSection.tsx","./src/components/settings/UsageSection.tsx","./src/components/settings/__tests__/CompressionSection.test.tsx","./src/components/settings/__tests__/HostDiscoverySection.test.tsx","./src/components/tour/TourHelpButton.tsx","./src/components/tour/TourOverlay.tsx","./src/components/tour/TourProvider.tsx","./src/components/tour/tourSteps.ts","./src/components/tour/useTourPositioning.ts","./src/components/tour/__tests__/Tour.test.tsx","./src/components/workflows/ActiveRunsPopover.tsx","./src/components/workflows/ApiCallAiHelper.tsx","./src/components/workflows/ApiCallStepCard.tsx","./src/components/workflows/ExecutionLimitsCard.tsx","./src/components/workflows/ImportDropzone.tsx","./src/components/workflows/QuickApiForm.tsx","./src/components/workflows/QuickPromptForm.tsx","./src/components/workflows/RunDetail.tsx","./src/components/workflows/WorkflowDetail.tsx","./src/components/workflows/WorkflowQuickStartPicker.tsx","./src/components/workflows/WorkflowWizard.tsx","./src/components/workflows/apiCallAuth.ts","./src/components/workflows/apiCallPlaceholders.ts","./src/components/workflows/apiCallPluginTips.ts","./src/components/workflows/apiCallSuggestions.ts","./src/components/workflows/parseBatchQAItems.ts","./src/components/workflows/__tests__/ActiveRunsPopover.test.tsx","./src/components/workflows/__tests__/ApiCallAiHelper.test.tsx","./src/components/workflows/__tests__/ApiCallStepCard.test.tsx","./src/components/workflows/__tests__/BatchItemsList.test.tsx","./src/components/workflows/__tests__/ExecutionLimitsCard.test.tsx","./src/components/workflows/__tests__/LiveFinishedBanner.test.tsx","./src/components/workflows/__tests__/QuickPromptForm.bindings.test.tsx","./src/components/workflows/__tests__/RunDetail.test.tsx","./src/components/workflows/__tests__/SmokeTests.test.tsx","./src/components/workflows/__tests__/WorkflowQuickStartPicker.test.tsx","./src/components/workflows/__tests__/apiCallAuth.test.ts","./src/components/workflows/__tests__/apiCallPlaceholders.test.ts","./src/components/workflows/__tests__/apiCallPluginTips.test.ts","./src/components/workflows/__tests__/apiCallSuggestions.test.ts","./src/components/workflows/__tests__/parseBatchQAItems.test.ts","./src/hooks/useApi.ts","./src/hooks/useAsyncGuard.ts","./src/hooks/useKonamiCode.ts","./src/hooks/useMatrixDecode.ts","./src/hooks/useMediaQuery.ts","./src/hooks/useQpChain.ts","./src/hooks/useRafBatchedStream.ts","./src/hooks/useToast.tsx","./src/hooks/useWebSocket.ts","./src/hooks/__tests__/useApi.test.ts","./src/hooks/__tests__/useAsyncGuard.test.tsx","./src/hooks/__tests__/useKonamiCode.test.tsx","./src/hooks/__tests__/useMatrixDecode.test.tsx","./src/hooks/__tests__/useMediaQuery.test.ts","./src/hooks/__tests__/useQpChain.test.ts","./src/hooks/__tests__/useRafBatchedStream.test.ts","./src/hooks/__tests__/useToast.test.tsx","./src/hooks/__tests__/useWebSocket.test.ts","./src/lib/I18nContext.tsx","./src/lib/ThemeContext.tsx","./src/lib/agent-question-parse.ts","./src/lib/api.ts","./src/lib/audit-resume.ts","./src/lib/autoTriggers.ts","./src/lib/bug-report.ts","./src/lib/chat-drafts.ts","./src/lib/constants.ts","./src/lib/diff-syntax.ts","./src/lib/downloadBlob.ts","./src/lib/emoji-autocomplete.ts","./src/lib/extractLikelyOutput.ts","./src/lib/gravatar.ts","./src/lib/i18n.ts","./src/lib/qp-improver-banner.ts","./src/lib/relativeTime.ts","./src/lib/scanUndeclaredVars.ts","./src/lib/stream-flush.ts","./src/lib/stream-watchdog.ts","./src/lib/stt-engine.ts","./src/lib/stt-models.ts","./src/lib/stt-worker.ts","./src/lib/tts-engine.ts","./src/lib/tts-models.ts","./src/lib/tts-utils.ts","./src/lib/tts-worker.ts","./src/lib/userError.ts","./src/lib/version.ts","./src/lib/workflow-quick-start.ts","./src/lib/workflowVariables.ts","./src/lib/__tests__/I18nContext.test.tsx","./src/lib/__tests__/ThemeContext.test.tsx","./src/lib/__tests__/access-warnings.test.ts","./src/lib/__tests__/agent-question-parse.test.ts","./src/lib/__tests__/api.fullAuditStream.legacyDocs.test.ts","./src/lib/__tests__/api.test.ts","./src/lib/__tests__/audit-resume.test.ts","./src/lib/__tests__/autoTriggers.test.ts","./src/lib/__tests__/batch-input-parse.test.ts","./src/lib/__tests__/batch-sidebar-grouping.test.ts","./src/lib/__tests__/bootstrap-signals.test.ts","./src/lib/__tests__/bug-report.test.ts","./src/lib/__tests__/chat-drafts.test.ts","./src/lib/__tests__/constants.test.ts","./src/lib/__tests__/cron-parsing.test.ts","./src/lib/__tests__/diff-syntax.test.ts","./src/lib/__tests__/downloadBlob.test.ts","./src/lib/__tests__/emoji-autocomplete.test.ts","./src/lib/__tests__/extractLikelyOutput.test.ts","./src/lib/__tests__/gravatar.test.ts","./src/lib/__tests__/i18n-parity.test.ts","./src/lib/__tests__/i18n.test.ts","./src/lib/__tests__/mcp-category-filter.test.ts","./src/lib/__tests__/qp-improver-banner.test.ts","./src/lib/__tests__/qp-improver-signal.test.ts","./src/lib/__tests__/quick-prompt-render.test.ts","./src/lib/__tests__/regression.test.ts","./src/lib/__tests__/relativeTime.test.ts","./src/lib/__tests__/scanUndeclaredVars.test.ts","./src/lib/__tests__/stream-flush.test.ts","./src/lib/__tests__/stream-watchdog.test.ts","./src/lib/__tests__/streaming.test.ts","./src/lib/__tests__/stt-engine.test.ts","./src/lib/__tests__/stt-models.test.ts","./src/lib/__tests__/tts-models.test.ts","./src/lib/__tests__/tts-utils.test.ts","./src/lib/__tests__/types.test.ts","./src/lib/__tests__/userError.test.ts","./src/lib/__tests__/version.test.ts","./src/lib/__tests__/workflow-quick-start.test.ts","./src/lib/__tests__/workflowVariables.test.ts","./src/lib/workflow-templates/chartbeat-top5.ts","./src/lib/workflow-templates/v07-presets.ts","./src/lib/workflow-templates/__tests__/chartbeat-top5.test.ts","./src/lib/workflow-templates/__tests__/v07-presets.test.ts","./src/pages/Dashboard.tsx","./src/pages/DiscussionsPage.tsx","./src/pages/McpPage.tsx","./src/pages/SettingsPage.tsx","./src/pages/SetupWizard.tsx","./src/pages/WorkflowsPage.tsx","./src/pages/__tests__/Dashboard.bootstrap-mcp.test.tsx","./src/pages/__tests__/Dashboard.test.tsx","./src/pages/__tests__/DiscussionsPage.test.tsx","./src/pages/__tests__/McpPage.test.tsx","./src/pages/__tests__/SettingsPage.test.tsx","./src/pages/__tests__/SetupWizard.test.tsx","./src/pages/__tests__/WorkflowsPage.qp-launch.test.tsx","./src/pages/__tests__/WorkflowsPage.requiredVars.test.tsx","./src/pages/__tests__/WorkflowsPage.test.tsx","./src/pages/__tests__/appendLiveBuffer.test.ts","./src/test/apiMock.complete.test.ts","./src/test/apiMock.ts","./src/test/setup.ts","./src/types/AgentConfig.ts","./src/types/AgentDetection.ts","./src/types/AgentProfile.ts","./src/types/AgentProjectUsage.ts","./src/types/AgentSettings.ts","./src/types/AgentType.ts","./src/types/AgentUsageSummary.ts","./src/types/AgentsConfig.ts","./src/types/AiAuditStatus.ts","./src/types/AiConfigStatus.ts","./src/types/AiConfigType.ts","./src/types/AiFileContent.ts","./src/types/AiFileNode.ts","./src/types/AiSearchResult.ts","./src/types/ApiAuthKind.ts","./src/types/ApiConfigKey.ts","./src/types/ApiEndpoint.ts","./src/types/ApiKey.ts","./src/types/ApiKeyDisplay.ts","./src/types/ApiKeysResponse.ts","./src/types/ApiSpec.ts","./src/types/AppConfig.ts","./src/types/AuditFileInfo.ts","./src/types/AuditInfo.ts","./src/types/AuditKind.ts","./src/types/AuditTodo.ts","./src/types/BootstrapProjectRequest.ts","./src/types/BootstrapProjectResponse.ts","./src/types/CloneProjectRequest.ts","./src/types/CloneProjectResponse.ts","./src/types/ConditionAction.ts","./src/types/Contact.ts","./src/types/CreateDirectiveRequest.ts","./src/types/CreateDiscussionRequest.ts","./src/types/CreateMcpConfigRequest.ts","./src/types/CreateProfileRequest.ts","./src/types/CreateSkillRequest.ts","./src/types/CreateWorkflowRequest.ts","./src/types/DailyUsage.ts","./src/types/DbExport.ts","./src/types/DbInfo.ts","./src/types/DetectedIp.ts","./src/types/DetectedRepo.ts","./src/types/Directive.ts","./src/types/DirectiveCategory.ts","./src/types/DiscoverKeysResponse.ts","./src/types/DiscoverReposRequest.ts","./src/types/DiscoverReposResponse.ts","./src/types/DiscoveredKey.ts","./src/types/Discussion.ts","./src/types/DiscussionMessage.ts","./src/types/DriftCheckResponse.ts","./src/types/DriftSection.ts","./src/types/ExecResponse.ts","./src/types/GitBranchRequest.ts","./src/types/GitBranchResponse.ts","./src/types/GitCommitRequest.ts","./src/types/GitCommitResponse.ts","./src/types/GitDiffQuery.ts","./src/types/GitDiffResponse.ts","./src/types/GitFileStatus.ts","./src/types/GitPushResponse.ts","./src/types/GitStatusResponse.ts","./src/types/HostSyncMode.ts","./src/types/ImportWorkflowRequest.ts","./src/types/LaunchAuditRequest.ts","./src/types/LinkMcpConfigRequest.ts","./src/types/McpConfig.ts","./src/types/McpConfigDisplay.ts","./src/types/McpContextEntry.ts","./src/types/McpDefinition.ts","./src/types/McpEnvEntry.ts","./src/types/McpIncompatibility.ts","./src/types/McpIncompleteConfig.ts","./src/types/McpOverview.ts","./src/types/McpServer.ts","./src/types/McpSource.ts","./src/types/McpTransport.ts","./src/types/MessageRole.ts","./src/types/ModelTier.ts","./src/types/ModelTierConfig.ts","./src/types/ModelTiersConfig.ts","./src/types/NetworkInfo.ts","./src/types/OAuth2ExtraHeader.ts","./src/types/OrchestrationRequest.ts","./src/types/PartialAuditRequest.ts","./src/types/ProfileCategory.ts","./src/types/Project.ts","./src/types/ProjectUsage.ts","./src/types/ProviderUsage.ts","./src/types/RemoteRepo.ts","./src/types/RepoSource.ts","./src/types/RetryConfig.ts","./src/types/RunStatus.ts","./src/types/SaveApiKeyRequest.ts","./src/types/ScanConfig.ts","./src/types/SendMessageRequest.ts","./src/types/ServerConfig.ts","./src/types/ServerConfigPublic.ts","./src/types/SetAgentAccessRequest.ts","./src/types/SetBriefingRequest.ts","./src/types/SetScanPathsRequest.ts","./src/types/SetupStatus.ts","./src/types/SetupStep.ts","./src/types/Skill.ts","./src/types/SkillCategory.ts","./src/types/StartBriefingResponse.ts","./src/types/StepConditionRule.ts","./src/types/StepMode.ts","./src/types/StepResult.ts","./src/types/StepType.ts","./src/types/TechDebtItem.ts","./src/types/TokenOverride.ts","./src/types/TokenUsageSummary.ts","./src/types/TokensConfig.ts","./src/types/TrackerSourceConfig.ts","./src/types/UpdateDiscussionRequest.ts","./src/types/UpdateMcpConfigRequest.ts","./src/types/UpdateMcpContextRequest.ts","./src/types/UpdateWorkflowRequest.ts","./src/types/Workflow.ts","./src/types/WorkflowAction.ts","./src/types/WorkflowRun.ts","./src/types/WorkflowRunSummary.ts","./src/types/WorkflowSafety.ts","./src/types/WorkflowStep.ts","./src/types/WorkflowSummary.ts","./src/types/WorkflowTrigger.ts","./src/types/WorkspaceConfig.ts","./src/types/WorkspaceHooks.ts","./src/types/WsMessage.ts","./src/types/extensions.d.ts","./src/types/generated.ts"],"version":"6.0.3"} \ No newline at end of file