diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 895d0ef7..ed18d6e0 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -11,10 +11,17 @@ on: # ship Node 24-compatible entrypoints — this just flips the runtime selector. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # 0.8.11 — onnxruntime-node's postinstall downloads CUDA GPU binaries from + # api.nuget.org on linux-x64: never used in CI (browser app → onnxruntime-web, + # CPU binaries bundled in the npm package) and api.nuget.org regularly times + # out on GitHub runners — THE recurring flake killing Security Scan / + # build-frontend at `pnpm install`. Documented knob of the package's install + # script; also set in frontend/Dockerfile + frontend/.npmrc. + ONNXRUNTIME_NODE_INSTALL: skip jobs: test-backend: - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest defaults: run: @@ -115,12 +122,34 @@ jobs: working-directory: frontend run: pnpm build + # 0.8.11 — lint gates. eslint fails on ERRORS (0 today) and ratchets the + # WARNING budget so the react-19-strict backlog can only shrink (see + # docs/tech-debt/TD-20260509-react19-effect-rules.md). lint:i18n fails on + # a missing/undefined translation key (caught `common.saving` at wire-up). + - name: Lint — eslint (0 errors, warning budget) + working-directory: frontend + run: pnpm lint --max-warnings 162 + - name: Lint — i18n key parity (fr/en/es) + working-directory: frontend + run: pnpm lint:i18n + - name: cargo check — desktop crate working-directory: desktop/src-tauri run: cargo check --locked # --locked so a drift in Cargo.lock vs Cargo.toml also fails here, # not just at release. + # 0.8.11 (C8) — typegen drift guard. Regenerate the per-type ts-rs + # bindings from the Rust models, then fail if a frontend-declared type in + # generated.ts is missing a field the Rust model has (the recurring + # "added a Rust field, forgot generated.ts" drift — hit 4× in 0.8.x). + - name: Types — ts-rs bindings + drift guard + # NOTE: this job's default working-directory is `backend` (see defaults + # at the top of the job) — paths below are relative to it. + run: | + cargo test export_bindings -- --nocapture + node ../frontend/scripts/check-types-drift.mjs + # ── Anti-regression lints ───────────────────────────────────────── # # These grep-based checks catch patterns that clippy can't: @@ -223,7 +252,7 @@ jobs: # as the AgentIo refactor removes the rust loop duplication. This is the # mechanical guard that stops agents (and humans) from merging dup'd code. duplication-check: - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -246,7 +275,7 @@ jobs: # 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' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -260,7 +289,7 @@ jobs: run: python3 -m unittest discover -s backend/scripts -p 'test_*.py' -v test-frontend: - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest defaults: run: @@ -302,7 +331,7 @@ jobs: # 0.6.0 "Create button silent", "JsonData ghost agent", "Quick APIs # tab buttons leak" were all UI-only bugs that pure-JS tests can't # see). - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest # ── Playwright official container ───────────────────────────────── # Chromium + chromium_headless_shell + ffmpeg + all OS deps are BAKED @@ -454,7 +483,7 @@ jobs: retention-days: 7 test-shell: - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -465,7 +494,7 @@ jobs: run: make test-shell security-scan: - if: github.event.label.name == 'ci-test' + if: contains(github.event.pull_request.labels.*.name, 'ci-test') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index daa6bbcf..8e6b61b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [0.8.11] - 2026-07-09 + +_« Socle clean » — the Phase-1 hardening batch from the 2026-07 full audit: security defaults, honest CI, operational reliability, and the two structural taxes (typegen, step dispatch) paid before the continual-learning work starts._ + +### Security + +- **Secure network defaults — no more accidental LAN exposure.** A stock `docker compose up` now publishes Kronn on `127.0.0.1` only (gateway + frontend), so a fresh install is not reachable from other machines. Exposing to the LAN is an explicit opt-in (`KRONN_BIND=0.0.0.0`) — and when you opt in, the backend **refuses to start unauthenticated**: it requires `KRONN_AUTH_TOKEN`, enabled auth, or an explicit `KRONN_ALLOW_INSECURE_LAN=1` acknowledgment. Closes the "any LAN peer could trigger an Exec step with your credentials (→ host root via the mounted docker socket)" chain flagged by the audit, while keeping the deliberate WSL-backend / Mac-frontend split working (opt-in + token). + +### Fixed + +- **CI can no longer pass by skipping.** The `ci-test` label gate used `github.event.label.name`, which is empty on `synchronize` (a push to the PR) — so every push after the label was applied skipped ALL jobs and the PR stayed green untested. Now gated on `contains(github.event.pull_request.labels.*.name, 'ci-test')`: once labelled, every subsequent push re-runs the full suite. +- **No more env-race flakes in the MCP scanner / host-discovery tests.** Eight tests mutate process-global env (`KRONN_HOST_HOME`, `KRONN_TEMPLATES_DIR`) and could clobber each other under the parallel test runner; they're now `#[serial]`. `cargo audit` ignores are documented with a rationale and a review-by date (deferral, not a permanent waiver). +- **MCP sidecar authenticates to the backend (no more silent 401 cross-machine).** When the backend has an API token configured, it now exports `KRONN_AUTH_TOKEN` into the process env; the `kronn-internal` sidecar inherits it and sends `Authorization: Bearer` on every call. Previously an auth-enabled or LAN-exposed instance (the WSL-backend / Mac-frontend split) returned a silent 401 to its own sidecar. Locked by a sidecar contract test. + +### Added + +- **Failure notifications for autonomous runs.** Set `server.failure_notify_url` (or `KRONN_FAILURE_NOTIFY_URL`) to a Slack/Teams/generic webhook and any scheduled or auto-triggered run that ends Failed / Interrupted / StoppedByGuard POSTs an alert — a cron that dies at 6am surfaces immediately instead of waiting to be noticed. Best-effort: a dead webhook never affects the run. +- **Scheduled DB backups, optionally outside the data volume.** A periodic backup (default every 24h, keeps the last 7) runs automatically. Point `KRONN_BACKUP_DIR` at a host-mounted directory (compose has a commented bind-mount) so a lost Docker volume no longer takes the backups with it; `KRONN_BACKUP_INTERVAL_HOURS=0` disables it. The in-volume default is kept but logged as a warning. +- **Runs interrupted by a backend restart are marked `Interrupted`, not left "Running" forever.** A run in flight when the process dies (crash, container restart, `kill -9`, cargo-watch reload) is reconciled at boot to a new terminal `Interrupted` status (neutral grey, distinct from a red `Failed`) — so the active-runs badge clears, and a cron's "did the last run succeed?" check no longer sees a zombie as in-progress or as a failure. + +### Changed + +- **Ollama context window is now model-aware — zero configuration.** The num_ctx cap was a fixed 8192, silently truncating any large prompt (a 40 KB review diff) unless the operator knew to set an env var — which no regular user ever would. Kronn now asks Ollama for the model's own trained context (`/api/show`, cached per model) and caps there, bounded by a RAM-safe 32K ceiling (the KV-cache CPU-spill cliff). `KRONN_OLLAMA_NUM_CTX_CAP` remains as an expert override that wins over everything, and a likely-truncation now logs a WARN instead of being silent. num_ctx stays auto-sized to the prompt, so memory only grows when a step actually needs it. +- **Ollama steps get compact skill injection (num_ctx budget).** Skills attached to an Ollama step were injected in FULL (~500-800+ tokens each — a real 6-skill review step measured ~6k tokens = 74% of the default 8192 num_ctx, crowding out the actual task context). Ollama now joins the compact-injection list (Codex/Kiro/Vibe): ~150-char summaries keep the pointer without the cost. Steps that need full rules should inline them in the prompt template (the N2 review template does exactly that). +- **THE recurring CI flake (onnxruntime-node CUDA download) is dead — root cause found.** The original fix (`package.json#pnpm.neverBuiltDependencies`) was silently ignored after the pnpm 11 upgrade (the field moved), so `onnxruntime-node`'s postinstall went back to downloading CUDA GPU binaries from api.nuget.org on linux-x64 — never used (browser app → onnxruntime-web; CPU binaries are bundled) and timing out on GitHub runners, killing Security Scan and build-frontend at `pnpm install`. Now skipped via the package's documented `ONNXRUNTIME_NODE_INSTALL=skip` knob (CI workflow env + frontend Dockerfile + .npmrc); the dead `pnpm` field is removed from package.json (constant warning noise). Also fixed the typegen drift-guard CI step running `cd backend` from inside `backend/` (the job's default working-directory). +- **A disabled workflow now explains itself instead of sitting mute.** Clicking "Lancer" on a disabled workflow (clones land disabled by design) used to do nothing, with zero feedback. The launch button now carries an explanatory tooltip (card + detail), the detail shows a "⏸ Désactivé" chip, and a one-click "Activer" button enables the workflow on the spot (with a success toast). The launch-variables modal flow (popup with fields, required-var blocking, trigger with values) is now pinned by tests — it had none. +- **Per-step model tier, selectable in the wizard.** An Agent step now has a tier picker (⚡ economy / 🎯 default / 🧠 reasoning) next to the agent — so one workflow can mix models (e.g. a cheap 8b filter step + a 32b reasoning gate for a local 2-stage review). The chosen tier shows as a small badge on the step in the workflow detail + compact pipeline. +- **Branch map: per-jump colours (CVD-validated), hover-to-isolate, fanned crossings.** The #15 branch map coloured every Goto the same; now each jump gets a distinct hue from a colourblind-safe categorical palette (validated per theme — dark uses Kronn's brand hues, light a distinct passing set), backward loops stay dashed (colour + shape, never colour alone), overlapping arcs fan out geometrically, and hovering the map dims all arcs but the one under the cursor so a crossing line is easy to follow. +- **Typed error codes on API responses (incremental).** `ApiResponse` gained an optional `error_code` (`not_found` / `validation` / `conflict` / `internal`) so the frontend and the MCP tools can branch on the KIND of failure instead of string-matching the message. Back-compatible (omitted from the wire when unset; legacy `err()` untouched); adopted first across the workflows handlers (not-found cases). Groundwork for the headless MCP surface. +- **UI language follows the browser on first load.** `getUILocale()` no longer hardcodes French — with no saved preference it detects `navigator.languages` (fr/en/es, else English), so the ~95% of the market that isn't francophone doesn't hit a French UI out of the box. The onboarding tour's section labels are now translated too (they were the last hardcoded-French strings; titles/bodies were already localized). +- **Step-type schema can't silently drift between Rust and the MCP sidecar.** The Python sidecar hand-lists the workflow step types (`step_types_closed_set`, read by agents to author workflows) with no compile link to Rust. A new guard test fails if that list drifts from the `StepType` enum. (The Rust runner's own dispatch is already exhaustive-`match`-checked at every site, so the compiler prevents forgetting a variant in the main/rollback/validation paths — the class of the 0.8.6 #59 incident; a full `StepExecutor` trait to de-duplicate those sites is deferred as an optional DRY refactor, not a correctness fix.) +- **Typegen drift is now caught in CI.** Adding a field to a Rust model but forgetting to mirror it in `frontend/src/types/generated.ts` was a silent, recurring bug (hit 4× in 0.8.x). A new field-level guard (`make typegen` + CI) regenerates the ts-rs bindings and fails if any frontend-declared type is missing a field the Rust model has. Back-filled the 32 fields that had already drifted across 10 types (e.g. `ServerConfig` carried only `host`+`port`, `WorkflowRun.state`, `WorkflowExportEnvelope.referenced_workflows`). See `TD-20260701` for the remaining full-regen migration (now guarded, no longer urgent). +- **Run history queries are bounded.** The unpaginated `GET /:id/runs` now caps at the 500 most recent runs (loading every row *with its full step results* on each page-open was the growth risk on a fast cron); the UI already folds + paginates. Optional retention (`server.run_retention_days`, default 0 = off) purges terminal runs older than N days at boot while preserving parent runs still referenced by a retained child — bounding the run table (76% of the DB) without silently dropping history unless you opt in. +- **Frontend lint gates are now enforced in CI.** `eslint` (0 errors, with a ratcheted warning budget so the React-19-strict backlog can only shrink) and `lint:i18n` (fails on a missing/undefined translation key — it caught `common.saving`, now added in all three locales) run on every CI push. Fixed the 31 pre-existing eslint errors (mostly useless-escapes in the i18n JSON examples; plus one real conditional-hook bug in `MarkdownContent`). +- **A vitest guard fails the build on phantom CSS variables.** Any `var(--kr-…)` used without a fallback and not defined in `tokens.css` now fails a test. Defined the ~20 phantom tokens the audit found (aliased to canonical tokens so they adapt across all themes; RGB triples for `rgba()` overlays in both dark and light). This is the class of bug behind the earlier black-on-dark contrast issue. + +--- + ## [0.8.10] - 2026-07-02 ### Security diff --git a/Makefile b/Makefile index 543d269f..9fb2322e 100644 --- a/Makefile +++ b/Makefile @@ -258,9 +258,14 @@ check: ## Generate TypeScript types from Rust models typegen: - @echo "$(GREEN)▸ Generating TypeScript types...$(RESET)" + @echo "$(GREEN)▸ Generating ts-rs bindings from Rust models...$(RESET)" cd backend && cargo test export_bindings -- --nocapture - @echo "$(GREEN)▸ Types written to frontend/src/types/generated.ts$(RESET)" + @echo "$(GREEN)▸ Checking generated.ts for field drift vs the Rust models...$(RESET)" + @node frontend/scripts/check-types-drift.mjs || { \ + echo "$(YELLOW)▸ generated.ts is missing fields — add them (see backend/bindings/.ts),$(RESET)"; \ + echo "$(YELLOW) or run 'node frontend/scripts/assemble-generated-types.mjs' for a full regen.$(RESET)"; \ + exit 1; } + @echo "$(GREEN)▸ generated.ts is in sync with the Rust models.$(RESET)" ## Run backend tests (skips ts-rs exports to avoid overwriting generated.ts) test-backend: diff --git a/VERSION b/VERSION index ef505616..83ce05d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.10 +0.8.11 diff --git a/backend/.cargo/audit.toml b/backend/.cargo/audit.toml index ffa0b5e6..14d2803f 100644 --- a/backend/.cargo/audit.toml +++ b/backend/.cargo/audit.toml @@ -13,6 +13,14 @@ # # TODO: drop these ignores once calamine releases a build on quick-xml >=0.41 # (track: https://github.com/tafia/calamine/issues). +# +# POLICY (0.8.11): every ignore below is DATED and must be re-reviewed at the +# review-by date — an ignore is a deferral, not a permanent waiver. At review: +# check whether calamine ships on quick-xml >=0.41 (`cargo update -p calamine`), +# and if so remove the entry. Do not add an ignore without a rationale + a +# review-by date. CI runs `cargo audit` (see .github/workflows/ci-test.yml). +# +# RUSTSEC-2026-0194 / -0195 — added 2026-06-30, review by 2026-09-30. [advisories] ignore = [ "RUSTSEC-2026-0194", diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f57c0761..544a03be 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1488,7 +1488,7 @@ dependencies = [ [[package]] name = "kronn" -version = "0.8.10" +version = "0.8.11" dependencies = [ "aes-gcm", "anyhow", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7375c5d1..d1f0f995 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kronn" -version = "0.8.10" +version = "0.8.11" edition = "2021" description = "Self-hosted AI dev workflow control plane" license = "AGPL-3.0-only" diff --git a/backend/scripts/test_disc_introspection_mcp.py b/backend/scripts/test_disc_introspection_mcp.py index eb8fa82f..5d712361 100644 --- a/backend/scripts/test_disc_introspection_mcp.py +++ b/backend/scripts/test_disc_introspection_mcp.py @@ -3472,5 +3472,51 @@ def test_limit_caps_results(self): self.assertEqual(out["disc_count"], 3) +class HttpAuthHeaderTests(unittest.TestCase): + """0.8.11 — the sidecar authenticates to the backend: `_http`/`_http_text` + send `Authorization: Bearer ` iff KRONN_AUTH_TOKEN is set. This is the + contract that makes an auth-enabled / LAN-exposed backend reachable by its + own sidecar (was a silent 401 before the boot injects the token).""" + + def setUp(self): + self.mod = _load_module() + + def _ok_response(self): + cm = mock.MagicMock() + cm.__enter__.return_value.read.return_value = b'{"success": true, "data": {}}' + cm.__exit__.return_value = False + return cm + + def test_http_adds_bearer_when_token_present(self): + with mock.patch.dict(os.environ, + {"KRONN_BACKEND_URL": "http://127.0.0.1:3140", "KRONN_AUTH_TOKEN": "sekret"}, + clear=True), \ + mock.patch("urllib.request.urlopen", return_value=self._ok_response()) as urlopen: + self.mod._http("GET", "/api/health") + req = urlopen.call_args.args[0] + self.assertEqual(req.get_header("Authorization"), "Bearer sekret") + + def test_http_omits_auth_when_no_token(self): + with mock.patch.dict(os.environ, + {"KRONN_BACKEND_URL": "http://127.0.0.1:3140"}, + clear=True), \ + mock.patch("urllib.request.urlopen", return_value=self._ok_response()) as urlopen: + self.mod._http("GET", "/api/health") + req = urlopen.call_args.args[0] + self.assertIsNone(req.get_header("Authorization")) + + def test_http_text_adds_bearer_when_token_present(self): + cm = mock.MagicMock() + cm.__enter__.return_value.read.return_value = b'{"kind":"kronn.workflow"}' + cm.__exit__.return_value = False + with mock.patch.dict(os.environ, + {"KRONN_BACKEND_URL": "http://127.0.0.1:3140", "KRONN_AUTH_TOKEN": "tok2"}, + clear=True), \ + mock.patch("urllib.request.urlopen", return_value=cm) as urlopen: + self.mod._http_text("GET", "/api/workflows/x/export") + req = urlopen.call_args.args[0] + self.assertEqual(req.get_header("Authorization"), "Bearer tok2") + + if __name__ == "__main__": unittest.main() diff --git a/backend/src/agents/runner.rs b/backend/src/agents/runner.rs index efb22593..0cb420de 100644 --- a/backend/src/agents/runner.rs +++ b/backend/src/agents/runner.rs @@ -583,8 +583,18 @@ pub async fn start_agent_with_config(config: AgentStartConfig<'_>) -> Result bulk + // context) — the ~150-char compact summary keeps the pointer without the + // cost. Steps that NEED full rules inline them in the prompt template. + let compact = matches!( + config.agent_type, + AgentType::Codex | AgentType::Kiro | AgentType::Vibe | AgentType::Ollama + ); // Ensure this run's skills/profiles exist as native files in the // directory the agent ACTUALLY runs in. @@ -1128,27 +1138,86 @@ pub(crate) fn disc_introspection_mcp_path_for_shared_config() -> Option const OLLAMA_NUM_CTX_CAP: u64 = 8192; const OLLAMA_NUM_CTX_FLOOR: u64 = 2048; +/// Absolute ceiling for the AUTO-derived cap (0.8.11). Even when a model +/// advertises a huge trained context (llama3.3:70b → 131072), allocating that +/// KV blind is exactly the 0.2 tok/s CPU-spill cliff documented above. 32K is +/// the RAM-blind safe upper bound; operators with headroom go beyond it via +/// the env override (which wins over everything). +const OLLAMA_NUM_CTX_AUTO_CEILING: u64 = 32768; + /// Pure parse of the ctx-cap override (split out so it's unit-testable without -/// mutating process env): a value below the floor or unparseable → the default. -pub(crate) fn parse_num_ctx_cap(raw: Option) -> u64 { +/// mutating process env): a value below the floor or unparseable → None (the +/// auto/model-derived path decides). +pub(crate) fn parse_num_ctx_cap(raw: Option) -> Option { raw.and_then(|v| v.trim().parse::().ok()) .filter(|&v| v >= OLLAMA_NUM_CTX_FLOOR) - .unwrap_or(OLLAMA_NUM_CTX_CAP) } -/// Effective ctx cap: `KRONN_OLLAMA_NUM_CTX_CAP` if set and sane, else the -/// portable default. Read per call — cheap, and lets an operator retune without -/// a rebuild. -pub(crate) fn ollama_num_ctx_cap() -> u64 { - parse_num_ctx_cap(std::env::var("KRONN_OLLAMA_NUM_CTX_CAP").ok()) +/// Effective ctx cap (0.8.11 — product default, zero configuration): +/// 1. `KRONN_OLLAMA_NUM_CTX_CAP` env — explicit operator override, wins. +/// 2. The MODEL's own trained context (from `/api/show`), clamped to +/// [FLOOR, AUTO_CEILING] — a user who pulled qwen3:32b gets its real +/// window automatically instead of a silent 8K truncation. +/// 3. Legacy portable default (8192) when Ollama can't be asked. +pub(crate) fn resolve_ctx_cap(env_raw: Option, model_limit: Option) -> u64 { + if let Some(v) = parse_num_ctx_cap(env_raw) { + return v; + } + match model_limit { + Some(l) => l.clamp(OLLAMA_NUM_CTX_FLOOR, OLLAMA_NUM_CTX_AUTO_CEILING), + None => OLLAMA_NUM_CTX_CAP, + } +} + +/// Extract a model's trained context length from an Ollama `/api/show` +/// response: `model_info` carries an arch-prefixed key (`qwen3.context_length`, +/// `llama.context_length`, …) — match on the suffix. Pure + unit-tested. +pub(crate) fn parse_context_length(show_response: &serde_json::Value) -> Option { + show_response + .get("model_info")? + .as_object()? + .iter() + .find(|(k, _)| k.ends_with(".context_length")) + .and_then(|(_, v)| v.as_u64()) } -/// Size the context window to the prompt, bounded by [FLOOR, CAP]. Coarse on +/// Ask Ollama for `model`'s trained context length, with a process-lifetime +/// cache (one `/api/show` per model per boot — the value is static per tag). +/// `None` on any failure: the caller falls back to the portable default. +async fn ollama_model_ctx_limit(base: &str, model: &str) -> Option { + static CACHE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + if let Some(hit) = cache.lock().ok().and_then(|c| c.get(model).copied()) { + return hit; + } + let fetched = async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .ok()?; + let resp = client + .post(format!("{}/api/show", base)) + .json(&serde_json::json!({ "model": model })) + .send() + .await + .ok()?; + let v: serde_json::Value = resp.json().await.ok()?; + parse_context_length(&v) + } + .await; + if let Ok(mut c) = cache.lock() { + c.insert(model.to_string(), fetched); + } + fetched +} + +/// Size the context window to the prompt, bounded by [FLOOR, cap]. Coarse on /// purpose (~3 chars/token + output headroom): this is a memory guard, not /// fine-grained sizing. -pub(crate) fn ollama_num_ctx(system_context: &str, user_prompt: &str) -> u64 { +pub(crate) fn ollama_num_ctx(system_context: &str, user_prompt: &str, ctx_cap: u64) -> u64 { let est = ((system_context.len() + user_prompt.len()) as u64 / 3) + 2048; - est.clamp(OLLAMA_NUM_CTX_FLOOR, ollama_num_ctx_cap()) + est.clamp(OLLAMA_NUM_CTX_FLOOR, ctx_cap.max(OLLAMA_NUM_CTX_FLOOR)) } /// qwen3 models are hybrid-reasoning. Ollama's `think:false` API flag is NOT @@ -1205,6 +1274,7 @@ pub(crate) fn build_ollama_chat_body( system_context: &str, user_prompt: &str, format: Option<&serde_json::Value>, + ctx_cap: u64, ) -> serde_json::Value { let mut messages = Vec::new(); if ollama_disables_thinking(model) { @@ -1224,7 +1294,7 @@ pub(crate) fn build_ollama_chat_body( "temperature": 0, "top_k": 1, "seed": 42, - "num_ctx": ollama_num_ctx(system_context, user_prompt), + "num_ctx": ollama_num_ctx(system_context, user_prompt, ctx_cap), }, }); if let Some(fmt) = format { @@ -1286,7 +1356,26 @@ async fn start_ollama_http( let base = crate::api::ollama::ollama_base_url_pub(); let url = format!("{}/api/chat", base); - let body = build_ollama_chat_body(model, system_context, user_prompt, format); + // 0.8.11 — ctx cap auto-derived from THE MODEL (its trained context via + // /api/show, cached), clamped to a RAM-safe ceiling. Zero configuration: + // a user who pulled qwen3:32b gets its real window instead of a silent 8K + // truncation. Env override still wins for experts. + let model_limit = ollama_model_ctx_limit(&base, model).await; + let ctx_cap = resolve_ctx_cap(std::env::var("KRONN_OLLAMA_NUM_CTX_CAP").ok(), model_limit); + let est = ((system_context.len() + user_prompt.len()) as u64 / 3) + 2048; + if est > ctx_cap { + tracing::warn!( + target: "kronn::ollama", + model = %model, + estimated_tokens = est, + ctx_cap = ctx_cap, + model_limit = ?model_limit, + "Prompt likely exceeds the context window — Ollama will TRUNCATE it. \ + Reduce the step's input, or raise KRONN_OLLAMA_NUM_CTX_CAP if your machine has headroom." + ); + } + + let body = build_ollama_chat_body(model, system_context, user_prompt, format, ctx_cap); // Observability: the effective num_ctx is the #1 confound for local perf — // an oversized window balloons the KV cache and spills onto the CPU (0.2 diff --git a/backend/src/agents/runner_test.rs b/backend/src/agents/runner_test.rs index 93f26651..9fc44613 100644 --- a/backend/src/agents/runner_test.rs +++ b/backend/src/agents/runner_test.rs @@ -76,7 +76,7 @@ mod tests { // generated text — greedy-stable ≠ bit-exact on Metal, would be flaky) ─ #[test] fn ollama_body_has_deterministic_options() { - let body = build_ollama_chat_body("qwen3:8b", "sys", "hi", None); + let body = build_ollama_chat_body("qwen3:8b", "sys", "hi", None, 8192); let opts = &body["options"]; assert_eq!(opts["temperature"], 0); assert_eq!(opts["top_k"], 1); @@ -88,12 +88,12 @@ mod tests { #[test] fn ollama_body_injects_no_think_for_qwen3_only() { // qwen3 → a dedicated `/no_think` system message is prepended. - let q = build_ollama_chat_body("qwen3:30b-a3b", "", "hi", None); + let q = build_ollama_chat_body("qwen3:30b-a3b", "", "hi", None, 8192); let msgs = q["messages"].as_array().unwrap(); assert_eq!(msgs[0]["role"], "system"); assert_eq!(msgs[0]["content"], "/no_think"); // Non-qwen3 (e.g. llama3.3) → no /no_think message at all. - let l = build_ollama_chat_body("llama3.3:70b", "", "hi", None); + let l = build_ollama_chat_body("llama3.3:70b", "", "hi", None, 8192); let lmsgs = l["messages"].as_array().unwrap(); assert!(!lmsgs.iter().any(|m| m["content"] == "/no_think"), "no_think must be qwen3-only"); @@ -102,21 +102,21 @@ mod tests { #[test] fn ollama_body_format_switches_to_non_stream() { // No format → stream text. - let free = build_ollama_chat_body("qwen3:8b", "", "hi", None); + let free = build_ollama_chat_body("qwen3:8b", "", "hi", None, 8192); assert_eq!(free["stream"], true); assert!(free.get("format").is_none()); // TypedSchema format → non-stream (one validated JSON blob) + schema passed through. let schema = serde_json::json!({"type":"object","properties":{"x":{"type":"integer"}}}); - let typed = build_ollama_chat_body("qwen3:8b", "", "hi", Some(&schema)); + let typed = build_ollama_chat_body("qwen3:8b", "", "hi", Some(&schema), 8192); assert_eq!(typed["stream"], false); assert_eq!(typed["format"], schema); } #[test] fn ollama_num_ctx_is_clamped_both_ends() { - assert_eq!(ollama_num_ctx("", ""), 2048, "tiny prompt → floor"); + assert_eq!(ollama_num_ctx("", "", 8192), 2048, "tiny prompt → floor"); let huge = "x".repeat(100_000); - assert_eq!(ollama_num_ctx(&huge, &huge), 8192, "huge prompt → cap"); + assert_eq!(ollama_num_ctx(&huge, &huge, 8192), 8192, "huge prompt → cap"); } #[tokio::test] @@ -982,16 +982,43 @@ Suite de la réponse."; #[test] fn parse_num_ctx_cap_honors_override_and_guards() { - // No override → portable default. - assert_eq!(parse_num_ctx_cap(None), 8192); - // Valid larger value (GPU box) → honored. - assert_eq!(parse_num_ctx_cap(Some("16384".into())), 16384); - assert_eq!(parse_num_ctx_cap(Some(" 32768 ".into())), 32768); - // Below the floor → rejected, falls back to default (never starve ctx). - assert_eq!(parse_num_ctx_cap(Some("512".into())), 8192); - // Garbage → default. - assert_eq!(parse_num_ctx_cap(Some("banana".into())), 8192); - assert_eq!(parse_num_ctx_cap(Some("".into())), 8192); + // 0.8.11 — parse returns Option: None lets the model-derived auto path decide. + assert_eq!(parse_num_ctx_cap(None), None); + assert_eq!(parse_num_ctx_cap(Some("16384".into())), Some(16384)); + assert_eq!(parse_num_ctx_cap(Some(" 32768 ".into())), Some(32768)); + // Below the floor → rejected (never starve ctx) → auto path. + assert_eq!(parse_num_ctx_cap(Some("512".into())), None); + assert_eq!(parse_num_ctx_cap(Some("banana".into())), None); + assert_eq!(parse_num_ctx_cap(Some("".into())), None); + } + + // ─── 0.8.11 — zero-config ctx cap: model-derived, env override wins ────── + #[test] + fn resolve_ctx_cap_env_wins_then_model_then_default() { + // Env override wins over everything (even a bigger model limit). + assert_eq!(resolve_ctx_cap(Some("24576".into()), Some(131072)), 24576); + // No env: the model's trained context, clamped to the RAM-safe ceiling. + assert_eq!(resolve_ctx_cap(None, Some(40960)), 32768, "qwen3 40K → ceiling 32K"); + assert_eq!(resolve_ctx_cap(None, Some(131072)), 32768, "llama3.3 131K → ceiling"); + assert_eq!(resolve_ctx_cap(None, Some(16384)), 16384, "model below ceiling → as-is"); + assert_eq!(resolve_ctx_cap(None, Some(1024)), 2048, "tiny model limit → floor"); + // Ollama unreachable → legacy portable default. + assert_eq!(resolve_ctx_cap(None, None), 8192); + // Bad env falls through to the model-derived path. + assert_eq!(resolve_ctx_cap(Some("banana".into()), Some(16384)), 16384); + } + + #[test] + fn parse_context_length_reads_arch_prefixed_key() { + let show = serde_json::json!({ + "model_info": { "general.architecture": "qwen3", "qwen3.context_length": 40960 } + }); + assert_eq!(parse_context_length(&show), Some(40960)); + let llama = serde_json::json!({ "model_info": { "llama.context_length": 131072 } }); + assert_eq!(parse_context_length(&llama), Some(131072)); + // No model_info / no matching key → None. + assert_eq!(parse_context_length(&serde_json::json!({})), None); + assert_eq!(parse_context_length(&serde_json::json!({"model_info": {"x": 1}})), None); } // ─── effective_model_flag: explicit model override beats tier ───────────── diff --git a/backend/src/api/contacts.rs b/backend/src/api/contacts.rs index 97645a0f..a460e081 100644 --- a/backend/src/api/contacts.rs +++ b/backend/src/api/contacts.rs @@ -251,6 +251,8 @@ mod tests { auth_token: None, auth_enabled: false, auth_strict_localhost: false, + failure_notify_url: None, + run_retention_days: 0, max_concurrent_agents: 5, agent_stall_timeout_min: 5, pseudo: None, diff --git a/backend/src/api/mcp_remote.rs b/backend/src/api/mcp_remote.rs index ceaad270..0db782ad 100644 --- a/backend/src/api/mcp_remote.rs +++ b/backend/src/api/mcp_remote.rs @@ -63,6 +63,7 @@ fn avg_workflow_duration_ms( | RunStatus::Failed | RunStatus::Cancelled | RunStatus::StoppedByGuard + | RunStatus::Interrupted ) }) .filter_map(|r| { @@ -325,6 +326,7 @@ fn is_terminal_status(status: &RunStatus) -> bool { | RunStatus::Failed | RunStatus::Cancelled | RunStatus::StoppedByGuard + | RunStatus::Interrupted ) } diff --git a/backend/src/api/workflows.rs b/backend/src/api/workflows.rs index ab1bc80a..ca2b8703 100644 --- a/backend/src/api/workflows.rs +++ b/backend/src/api/workflows.rs @@ -716,7 +716,7 @@ pub async fn get( ) -> Json> { match state.db.with_conn(move |conn| crate::db::workflows::get_workflow(conn, &id)).await { Ok(Some(wf)) => Json(ApiResponse::ok(wf)), - Ok(None) => Json(ApiResponse::err("Workflow not found")), + Ok(None) => Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Workflow not found")), Err(e) => Json(ApiResponse::err(format!("DB error: {}", e))), } } @@ -910,7 +910,7 @@ pub async fn update( let wf_id = id.clone(); let existing = match state.db.with_conn(move |conn| crate::db::workflows::get_workflow(conn, &wf_id)).await { Ok(Some(wf)) => wf, - Ok(None) => return Json(ApiResponse::err("Workflow not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Workflow not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; @@ -1354,7 +1354,7 @@ pub async fn import_workflow( Ok(root_out) }).await { Ok(Some(w)) => Json(ApiResponse::ok(w)), - Ok(None) => Json(ApiResponse::err("Import interne : workflow racine introuvable après insertion")), + Ok(None) => Json(ApiResponse::err_coded(ApiErrorCode::Internal, "Import interne : workflow racine introuvable après insertion")), Err(e) => Json(ApiResponse::err(format!("DB error: {}", e))), } } @@ -2129,7 +2129,7 @@ pub async fn decide_run( .await { Ok(Some(r)) => r, - Ok(None) => return Json(ApiResponse::err("Run not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Run not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; @@ -2164,7 +2164,7 @@ pub async fn decide_run( .await { Ok(Some(wf)) => wf, - Ok(None) => return Json(ApiResponse::err("Workflow not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Workflow not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; @@ -2277,7 +2277,7 @@ pub async fn get_run( ) -> Json> { match state.db.with_conn(move |conn| crate::db::workflows::get_run(conn, &run_id)).await { Ok(Some(run)) => Json(ApiResponse::ok(run)), - Ok(None) => Json(ApiResponse::err("Run not found")), + Ok(None) => Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Run not found")), Err(e) => Json(ApiResponse::err(format!("DB error: {}", e))), } } @@ -2351,7 +2351,7 @@ pub async fn test_worktree( move |conn| crate::db::workflows::get_run(conn, &run_id) }).await { Ok(Some(r)) => r, - Ok(None) => return Json(ApiResponse::err("Run not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Run not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; @@ -2367,14 +2367,14 @@ pub async fn test_worktree( move |conn| crate::db::workflows::get_workflow(conn, &wf_id) }).await { Ok(Some(w)) => w, - Ok(None) => return Json(ApiResponse::err("Parent workflow not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Parent workflow not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; let project_path = if let Some(pid) = workflow.project_id.clone() { match state.db.with_conn(move |conn| crate::db::projects::get_project(conn, &pid)).await { Ok(Some(p)) => p.path, - _ => return Json(ApiResponse::err("Project not found for this workflow")), + _ => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Project not found for this workflow")), } } else { return Json(ApiResponse::err("Workflow has no project — cannot create a test worktree")); @@ -2439,7 +2439,7 @@ pub async fn delete_test_worktree( move |conn| crate::db::workflows::get_run(conn, &run_id) }).await { Ok(Some(r)) => r, - Ok(None) => return Json(ApiResponse::err("Run not found")), + Ok(None) => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Run not found")), Err(e) => return Json(ApiResponse::err(format!("DB error: {}", e))), }; @@ -2448,7 +2448,7 @@ pub async fn delete_test_worktree( move |conn| crate::db::workflows::get_workflow(conn, &wf_id) }).await { Ok(Some(w)) => w, - _ => return Json(ApiResponse::err("Parent workflow not found")), + _ => return Json(ApiResponse::err_coded(ApiErrorCode::NotFound, "Parent workflow not found")), }; let project_path = if let Some(pid) = workflow.project_id.clone() { diff --git a/backend/src/core/backup.rs b/backend/src/core/backup.rs new file mode 100644 index 00000000..37c25f64 --- /dev/null +++ b/backend/src/core/backup.rs @@ -0,0 +1,191 @@ +//! B6 (0.8.11) — scheduled DB backups. The manual `/api/db/backup` writes into +//! `/backups` — i.e. INSIDE the live DB volume, so losing the volume +//! loses the DB AND its backups. This module runs an automatic periodic backup +//! that can target a directory OUTSIDE the volume (bind-mounted host dir via +//! `KRONN_BACKUP_DIR`) and prunes to a rolling window. +//! +//! Pure helpers (`resolve_backup_dir`, `prune_old_backups`, `backup_filename`) +//! are unit-tested; the SQLite copy + the interval loop are thin wrappers. +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use crate::db::Database; + +/// Prefix + extension for scheduled backup files (distinct enough to prune +/// safely without touching unrelated files in a shared dir). +const BACKUP_PREFIX: &str = "kronn-auto-"; +const BACKUP_EXT: &str = "db"; + +/// Where scheduled backups go: `KRONN_BACKUP_DIR` if set (operator points this +/// at a bind-mounted host dir OUTSIDE the data volume), else `/backups` +/// (same place as the manual backup — in-volume, logged as a warning). Returns +/// `(dir, is_external)`. +pub fn resolve_backup_dir(data_dir: &Path) -> (PathBuf, bool) { + match std::env::var("KRONN_BACKUP_DIR").ok().filter(|s| !s.trim().is_empty()) { + Some(dir) => (PathBuf::from(dir.trim()), true), + None => (data_dir.join("backups"), false), + } +} + +/// Timestamped backup filename for a given instant. +pub fn backup_filename(now: DateTime) -> String { + format!("{BACKUP_PREFIX}{}.{BACKUP_EXT}", now.format("%Y%m%d-%H%M%S")) +} + +/// Delete the oldest scheduled backups in `dir`, keeping the `keep_n` most +/// recent (by filename, which sorts chronologically thanks to the timestamp +/// format). Only touches files matching our prefix/ext. Returns how many were +/// removed. Never errors on individual unlink failures (best-effort). +pub fn prune_old_backups(dir: &Path, keep_n: usize) -> usize { + let mut ours: Vec = match std::fs::read_dir(dir) { + Ok(rd) => rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with(BACKUP_PREFIX) && n.ends_with(&format!(".{BACKUP_EXT}"))) + .unwrap_or(false) + }) + .collect(), + Err(_) => return 0, + }; + if ours.len() <= keep_n { + return 0; + } + ours.sort(); // chronological (timestamped names) + let to_remove = ours.len() - keep_n; + let mut removed = 0; + for p in ours.into_iter().take(to_remove) { + if std::fs::remove_file(&p).is_ok() { + removed += 1; + } + } + removed +} + +/// Run one backup now: SQLite online-copy the live DB into `dir`, then prune to +/// `keep_n`. Returns the written path. Skips (Ok(None)) for an in-memory DB. +pub async fn perform_backup(db: &Database, dir: &Path, keep_n: usize) -> anyhow::Result> { + if db.path().to_string_lossy() == ":memory:" { + return Ok(None); + } + std::fs::create_dir_all(dir)?; + let dest = dir.join(backup_filename(Utc::now())); + let dest_owned = dest.clone(); + db.with_conn(move |conn| { + let mut dst = rusqlite::Connection::open(&dest_owned)?; + let backup = rusqlite::backup::Backup::new(conn, &mut dst)?; + backup.run_to_completion(5, Duration::from_millis(50), None)?; + Ok(()) + }) + .await + .map_err(|e| { + let _ = std::fs::remove_file(&dest); + anyhow::anyhow!("scheduled backup failed: {e}") + })?; + let pruned = prune_old_backups(dir, keep_n); + if pruned > 0 { + tracing::info!(target: "backup", "pruned {pruned} old scheduled backup(s)"); + } + Ok(Some(dest)) +} + +/// Periodic backup task. Mirrors `learning_sweep`: tick on an interval, run one +/// backup, log failures, never crash the loop. +pub struct BackupScheduler { + db: Arc, + interval: Duration, + keep_n: usize, +} + +impl BackupScheduler { + /// Build from env: `KRONN_BACKUP_INTERVAL_HOURS` (default 24, 0 disables), + /// `KRONN_BACKUP_KEEP` (default 7). + pub fn from_env(db: Arc) -> Option> { + let hours: u64 = std::env::var("KRONN_BACKUP_INTERVAL_HOURS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(24); + if hours == 0 { + tracing::info!(target: "backup", "scheduled backups disabled (KRONN_BACKUP_INTERVAL_HOURS=0)"); + return None; + } + let keep_n: usize = std::env::var("KRONN_BACKUP_KEEP") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(7); + Some(Arc::new(Self { db, interval: Duration::from_secs(hours * 3600), keep_n })) + } + + pub async fn start(self: Arc) { + let (dir, external) = resolve_backup_dir(self.db.path().parent().unwrap_or(Path::new("."))); + if !external { + tracing::warn!( + target: "backup", + "scheduled backups write to {} (INSIDE the data volume). Set KRONN_BACKUP_DIR to a host-mounted dir so a lost volume doesn't lose the backups too.", + dir.display() + ); + } else { + tracing::info!(target: "backup", "scheduled backups → {} (external)", dir.display()); + } + let mut tick = tokio::time::interval(self.interval); + loop { + tick.tick().await; + match perform_backup(&self.db, &dir, self.keep_n).await { + Ok(Some(p)) => tracing::info!(target: "backup", "scheduled backup written: {}", p.display()), + Ok(None) => {} + Err(e) => tracing::warn!(target: "backup", "{e}"), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_backup_dir_defaults_in_volume_then_env_external() { + std::env::remove_var("KRONN_BACKUP_DIR"); + let (dir, ext) = resolve_backup_dir(Path::new("/data")); + assert_eq!(dir, PathBuf::from("/data/backups")); + assert!(!ext, "default is in-volume"); + + std::env::set_var("KRONN_BACKUP_DIR", "/host/backups"); + let (dir, ext) = resolve_backup_dir(Path::new("/data")); + assert_eq!(dir, PathBuf::from("/host/backups")); + assert!(ext, "env-provided dir is external"); + std::env::remove_var("KRONN_BACKUP_DIR"); + } + + #[test] + fn backup_filename_is_prefixed_and_timestamped() { + let ts = DateTime::parse_from_rfc3339("2026-07-07T06:05:04Z").unwrap().with_timezone(&Utc); + assert_eq!(backup_filename(ts), "kronn-auto-20260707-060504.db"); + } + + #[test] + fn prune_keeps_n_most_recent_and_ignores_foreign_files() { + let tmp = std::env::temp_dir().join(format!("kronn-prune-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // 5 of ours + 1 foreign. + for ts in ["20260101-000000","20260102-000000","20260103-000000","20260104-000000","20260105-000000"] { + std::fs::write(tmp.join(format!("kronn-auto-{ts}.db")), b"x").unwrap(); + } + std::fs::write(tmp.join("important.db"), b"keep").unwrap(); + + let removed = prune_old_backups(&tmp, 2); + assert_eq!(removed, 3, "5 ours, keep 2 → remove 3"); + assert!(tmp.join("kronn-auto-20260104-000000.db").exists(), "newest kept"); + assert!(tmp.join("kronn-auto-20260105-000000.db").exists(), "newest kept"); + assert!(!tmp.join("kronn-auto-20260101-000000.db").exists(), "oldest pruned"); + assert!(tmp.join("important.db").exists(), "foreign file untouched"); + + // Under the keep count → no-op. + assert_eq!(prune_old_backups(&tmp, 10), 0); + std::fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/backend/src/core/config.rs b/backend/src/core/config.rs index e1fa8bb0..343e7e1b 100644 --- a/backend/src/core/config.rs +++ b/backend/src/core/config.rs @@ -243,6 +243,8 @@ pub fn default_config() -> AppConfig { auth_token: None, auth_enabled: false, auth_strict_localhost: false, + failure_notify_url: None, + run_retention_days: 0, max_concurrent_agents: 5, agent_stall_timeout_min: 5, pseudo: None, diff --git a/backend/src/core/host_mcp_discovery.rs b/backend/src/core/host_mcp_discovery.rs index 077c6c23..52383438 100644 --- a/backend/src/core/host_mcp_discovery.rs +++ b/backend/src/core/host_mcp_discovery.rs @@ -440,6 +440,7 @@ fn sort_keys(mut v: Vec) -> Vec { #[cfg(test)] mod tests { + use serial_test::serial; use super::*; use std::collections::HashSet; use std::fs; @@ -738,6 +739,7 @@ ATLASSIAN_TOKEN = "x" } #[test] + #[serial] fn entry_without_transport_is_skipped() { let tmp = TempDir::new().unwrap(); let claude = r#"{ @@ -755,6 +757,7 @@ ATLASSIAN_TOKEN = "x" /// Risk is bounded: only KRONN_HOST_HOME is mutated and other tests /// don't read it. If flake surfaces, gate behind `--test-threads=1`. #[test] + #[serial] fn resolve_home_prefers_kronn_host_home() { let original = std::env::var("KRONN_HOST_HOME").ok(); std::env::set_var("KRONN_HOST_HOME", "/host/path"); diff --git a/backend/src/core/mcp_scanner_test.rs b/backend/src/core/mcp_scanner_test.rs index 2b994430..053c36b8 100644 --- a/backend/src/core/mcp_scanner_test.rs +++ b/backend/src/core/mcp_scanner_test.rs @@ -309,6 +309,7 @@ mod tests { // ─── ensure_redirectors ──────────────────────────────────────────────── #[test] + #[serial] #[serial(kronn_templates_env)] fn ensure_redirectors_skips_projects_without_ai_dir() { let tmp = setup_tmp("redir-no-ai"); @@ -323,6 +324,7 @@ mod tests { } #[test] + #[serial] #[serial(kronn_templates_env)] fn ensure_redirectors_creates_missing_files() { let tmp = setup_tmp("redir-create"); @@ -355,6 +357,7 @@ mod tests { } #[test] + #[serial] #[serial(kronn_templates_env)] fn ensure_redirectors_does_not_overwrite_existing() { let tmp = setup_tmp("redir-no-overwrite"); @@ -1793,6 +1796,7 @@ args = ["@example/old-mcp"] use crate::core::mcp_scanner::{CodexSync, CopilotSync, HostMcpSync}; #[test] + #[serial] fn codex_global_sync_emits_kronn_internal_into_config_toml() { // Redirect Codex's home-config lookup to a tmp dir via the // KRONN_HOST_HOME hook. We don't share global env between tests @@ -1836,6 +1840,7 @@ args = ["@example/old-mcp"] } #[test] + #[serial] fn copilot_global_sync_emits_kronn_internal_into_mcp_config_json() { let tmp = setup_tmp("copilot-global-inject"); let home = tmp.join("fake-home"); diff --git a/backend/src/core/mod.rs b/backend/src/core/mod.rs index 94a845db..af81eb8f 100644 --- a/backend/src/core/mod.rs +++ b/backend/src/core/mod.rs @@ -38,7 +38,9 @@ pub mod user_context; pub mod sse_limits; pub mod worktree; pub mod tailscale; +pub mod backup; pub mod net_expose; +pub mod run_notify; pub mod ws_client; pub mod log_buffer; pub mod rtk_detect; diff --git a/backend/src/core/net_expose.rs b/backend/src/core/net_expose.rs index c0d27d5c..fa78771b 100644 --- a/backend/src/core/net_expose.rs +++ b/backend/src/core/net_expose.rs @@ -40,6 +40,52 @@ pub fn restart_required(configured_host: &str) -> bool { } } +/// True when `host` binds ONLY loopback — safe, unreachable from other machines. +/// Stricter than `!is_exposed_host`: a specific LAN IP (`192.168.x`) is NOT +/// loopback (it IS reachable), whereas the `is_exposed_host` toggle semantics +/// only flag the bind-all forms. Used by the boot security guard. +pub fn is_loopback_host(host: &str) -> bool { + let h = host.trim(); + h == "127.0.0.1" || h == "localhost" || h == "::1" || h.starts_with("127.") +} + +/// Does the operator intend LAN exposure? In Docker the backend ALWAYS binds +/// `0.0.0.0` (nginx must reach it inside the network), so the real LAN-exposure +/// lever is the *host port publish* — signalled to us by `KRONN_BIND`. Natively, +/// the resolved bind host is authoritative. Pure + tested. +pub fn lan_exposed(is_docker: bool, kronn_bind: Option<&str>, native_host: &str) -> bool { + if is_docker { + kronn_bind.map(|b| !is_loopback_host(b)).unwrap_or(false) + } else { + !is_loopback_host(native_host) + } +} + +/// Boot security guard: `Some(message)` when the instance is LAN-exposed but the +/// API is unauthenticated (no `auth_enabled`, no token) and the operator did not +/// acknowledge the risk. Secure-by-default: a fresh `docker compose up` binds +/// loopback (→ not exposed → `None`); exposing to the LAN requires either auth +/// or an explicit `KRONN_ALLOW_INSECURE_LAN` acknowledgment. Pure + tested. +pub fn insecure_lan_boot_error( + lan_exposed: bool, + auth_enabled: bool, + token_configured: bool, + ack_insecure: bool, +) -> Option { + if ack_insecure || auth_enabled || token_configured || !lan_exposed { + return None; + } + Some( + "REFUSING TO START: Kronn is exposed to the network (LAN/Tailscale) but the API \ + is unauthenticated — any peer on your network could trigger workflows (incl. Exec \ + steps) with your credentials.\n Fix one of:\n • set KRONN_AUTH_TOKEN= \ + (recommended — the frontend and MCP sidecar pick it up automatically), or\n • \ + enable auth in Settings, or\n • set KRONN_ALLOW_INSECURE_LAN=1 to keep the API \ + open on a trusted home LAN, at your own risk." + .to_string(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -65,6 +111,45 @@ mod tests { } } + #[test] + fn loopback_detection_is_strict() { + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("::1")); + assert!(is_loopback_host("127.0.1.1")); + assert!(is_loopback_host(" 127.0.0.1 ")); + // A specific LAN IP IS reachable → NOT loopback (stricter than the toggle). + assert!(!is_loopback_host("192.168.1.10")); + assert!(!is_loopback_host("0.0.0.0")); + assert!(!is_loopback_host("::")); + } + + #[test] + fn lan_exposed_is_docker_aware() { + // Docker: internal bind is always 0.0.0.0 — only KRONN_BIND (host publish) counts. + assert!(!lan_exposed(true, Some("127.0.0.1"), "0.0.0.0"), "docker loopback publish = safe"); + assert!(!lan_exposed(true, None, "0.0.0.0"), "docker no KRONN_BIND = default safe"); + assert!(lan_exposed(true, Some("0.0.0.0"), "0.0.0.0"), "docker opted into LAN publish"); + // Native: the resolved bind host is authoritative. + assert!(!lan_exposed(false, None, "127.0.0.1")); + assert!(lan_exposed(false, None, "0.0.0.0")); + assert!(lan_exposed(false, None, "192.168.1.10")); + } + + #[test] + fn insecure_lan_guard_only_fires_when_exposed_and_unauthenticated() { + // Safe: not exposed → never blocks, regardless of auth. + assert!(insecure_lan_boot_error(false, false, false, false).is_none()); + // Exposed + unauthenticated + not acknowledged → BLOCK. + assert!(insecure_lan_boot_error(true, false, false, false).is_some()); + // Exposed but authenticated (token) → fine. + assert!(insecure_lan_boot_error(true, false, true, false).is_none()); + // Exposed but auth_enabled → fine. + assert!(insecure_lan_boot_error(true, true, false, false).is_none()); + // Exposed + unauthenticated but explicitly acknowledged → allowed. + assert!(insecure_lan_boot_error(true, false, false, true).is_none()); + } + #[test] fn record_bound_host_then_restart_required_reflects_diff() { // First write wins; assert the logic via boot_exposed once set. We don't diff --git a/backend/src/core/run_notify.rs b/backend/src/core/run_notify.rs new file mode 100644 index 00000000..33089434 --- /dev/null +++ b/backend/src/core/run_notify.rs @@ -0,0 +1,119 @@ +//! B6 (0.8.11) — failure notifications for scheduled / auto-triggered workflow +//! runs. An autonomous cron that dies mid-run used to be discovered only by +//! opening the UI; when `server.failure_notify_url` (or the +//! `KRONN_FAILURE_NOTIFY_URL` env) is set, a run ending in a non-success +//! terminal state POSTs a Slack/Teams/generic-JSON message instead. +//! +//! Best-effort by design: a dead or slow webhook must NEVER affect the run +//! itself — every error is logged and swallowed. +use crate::models::{RunStatus, Workflow, WorkflowRun}; +use crate::AppState; + +/// True for terminal states that warrant an alert — a real failure, a +/// guard-stop, or an interruption (backend died mid-run). Success / Cancelled +/// (user-initiated) / non-terminal states never notify. +pub fn should_notify(status: &RunStatus) -> bool { + matches!( + status, + RunStatus::Failed | RunStatus::Interrupted | RunStatus::StoppedByGuard + ) +} + +/// Slack/Teams/generic-webhook compatible JSON body (`{"text": …}`). Takes the +/// primitive fields (not the whole run) so it is trivially unit-testable. +pub fn build_payload(workflow_name: &str, status: &RunStatus, run_id: &str, started_at_rfc3339: &str) -> String { + let label = match status { + RunStatus::Failed => "FAILED", + RunStatus::Interrupted => "INTERRUPTED (backend restarted mid-run)", + RunStatus::StoppedByGuard => "STOPPED BY GUARD", + _ => "ended", + }; + let text = format!( + "⚠️ Kronn — workflow “{}” run {}\n• run_id: {}\n• started: {}", + workflow_name, label, run_id, started_at_rfc3339 + ); + serde_json::json!({ "text": text }).to_string() +} + +/// Resolve the effective notify URL (config first, then env), trimmed & non-empty. +fn resolve_url(cfg_url: Option) -> Option { + cfg_url + .or_else(|| std::env::var("KRONN_FAILURE_NOTIFY_URL").ok()) + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) +} + +/// Fire the failure webhook if the run failed and a URL is configured. +pub async fn notify_if_failed(state: &AppState, workflow: &Workflow, run: &WorkflowRun) { + if !should_notify(&run.status) { + return; + } + let cfg_url = { state.config.read().await.server.failure_notify_url.clone() }; + let Some(url) = resolve_url(cfg_url) else { + return; + }; + let body = build_payload(&workflow.name, &run.status, &run.id, &run.started_at.to_rfc3339()); + // Bounded client: reqwest's default has NO request timeout, so a hanging + // webhook endpoint would pin this task forever and accumulate across + // failed runs — the opposite of "best-effort" (Copilot review, PR #114). + let Ok(client) = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(5)) + .timeout(std::time::Duration::from_secs(10)) + .build() + else { + tracing::warn!("Failure notification skipped: could not build HTTP client"); + return; + }; + let send = client + .post(&url) + .header("content-type", "application/json") + .body(body) + .send() + .await; + match send { + Ok(r) if r.status().is_success() => { + tracing::info!("Failure notification sent for run {} ({:?})", run.id, run.status) + } + Ok(r) => tracing::warn!( + "Failure notification returned {} for run {}", + r.status(), + run.id + ), + Err(e) => tracing::warn!("Failure notification POST failed for run {}: {}", run.id, e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_notify_only_on_non_success_terminal() { + assert!(should_notify(&RunStatus::Failed)); + assert!(should_notify(&RunStatus::Interrupted)); + assert!(should_notify(&RunStatus::StoppedByGuard)); + assert!(!should_notify(&RunStatus::Success)); + assert!(!should_notify(&RunStatus::Cancelled)); + assert!(!should_notify(&RunStatus::Running)); + assert!(!should_notify(&RunStatus::WaitingApproval)); + } + + #[test] + fn payload_is_slack_shaped_and_names_the_workflow_and_status() { + let body = build_payload("PR Review cron", &RunStatus::Failed, "run-1", "2026-07-07T06:00:00Z"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let text = v["text"].as_str().unwrap(); + assert!(text.contains("PR Review cron")); + assert!(text.contains("FAILED")); + assert!(text.contains("run-1")); + } + + #[test] + fn resolve_url_prefers_config_trims_and_rejects_empty() { + assert_eq!(resolve_url(Some(" https://hook ".into())).as_deref(), Some("https://hook")); + assert_eq!(resolve_url(Some(" ".into())), None); + // No config, no env → None. + std::env::remove_var("KRONN_FAILURE_NOTIFY_URL"); + assert_eq!(resolve_url(None), None); + } +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 89ee4aba..d6efe47b 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -110,6 +110,19 @@ impl Database { Err(e) => tracing::warn!("Failed to reconcile stale audit_runs: {}", e), } + // 0.8.11 (B5) — same reconcile for workflow_runs: a run that was in + // flight when the process died stays `Running`/`Pending` forever, + // poisoning the active-runs badge and cron "last run" checks. + // Cutoff 0 (not 30 min): at BOOT there is no in-process runner state, + // so every `Running`/`Pending` row is by definition a zombie — a grace + // window would just leave a freshly-interrupted run lying about its + // status for up to that long (Copilot review, PR #114). + match workflows::reconcile_stale_runs(&conn, 0) { + Ok(0) => {} + Ok(n) => tracing::info!("Reconciled {} zombie workflow_runs left 'Running'/'Pending' by a previous process → Interrupted", n), + Err(e) => tracing::warn!("Failed to reconcile stale workflow_runs: {}", e), + } + // 0.8.6 — auto-purge api_call_logs older than 90 days at boot. // Generous default : keeps a quarter of audit trail for debug // while preventing unbounded growth. User can manually trigger diff --git a/backend/src/db/tests.rs b/backend/src/db/tests.rs index 6a972fde..d469d519 100644 --- a/backend/src/db/tests.rs +++ b/backend/src/db/tests.rs @@ -1375,6 +1375,100 @@ fn workflow_runs_insert_and_list() { assert_eq!(runs[0].status, RunStatus::Running); } +#[test] +fn purge_runs_older_than_deletes_old_terminal_but_preserves_parents_and_recent() { + use chrono::Duration; + let conn = test_db(); + crate::db::workflows::insert_workflow(&conn, &sample_workflow("w1")).unwrap(); + let old = || Utc::now() - Duration::days(100); + + // A parent (old, terminal) referenced by a child → must be PRESERVED. + let mut parent = sample_run("parent", "w1"); + parent.status = RunStatus::Success; + parent.finished_at = Some(old()); + crate::db::workflows::insert_run(&conn, &parent).unwrap(); + + let mut child = sample_run("child", "w1"); + child.status = RunStatus::Success; + child.parent_run_id = Some("parent".into()); + child.finished_at = Some(old()); + crate::db::workflows::insert_run(&conn, &child).unwrap(); + + // Old standalone terminal → DELETED. + let mut old_standalone = sample_run("old-standalone", "w1"); + old_standalone.status = RunStatus::Failed; + old_standalone.finished_at = Some(old()); + crate::db::workflows::insert_run(&conn, &old_standalone).unwrap(); + + // Recent terminal → kept (within window). + let mut recent = sample_run("recent", "w1"); + recent.status = RunStatus::Success; + recent.finished_at = Some(Utc::now()); + crate::db::workflows::insert_run(&conn, &recent).unwrap(); + + // Old but still Running (no finished_at) → never purged. + let mut running = sample_run("running", "w1"); + running.status = RunStatus::Running; + running.started_at = old(); + crate::db::workflows::insert_run(&conn, &running).unwrap(); + + let n = crate::db::workflows::purge_runs_older_than(&conn, 90).unwrap(); + assert_eq!(n, 2, "old standalone terminal + the (unreferenced-after) child"); + + let exists = |id: &str| crate::db::workflows::get_run(&conn, id).unwrap().is_some(); + assert!(exists("parent"), "parent referenced by a child is preserved"); + assert!(!exists("old-standalone"), "old standalone terminal purged"); + assert!(!exists("child"), "old terminal child purged"); + assert!(exists("recent"), "recent run kept"); + assert!(exists("running"), "non-terminal run never purged"); +} + +#[test] +fn reconcile_stale_runs_flips_only_old_running_pending_to_interrupted() { + use chrono::Duration; + let conn = test_db(); + crate::db::workflows::insert_workflow(&conn, &sample_workflow("w1")).unwrap(); + + // Stale: Running, started 1h ago → should become Interrupted. + let mut stale = sample_run("stale", "w1"); + stale.status = RunStatus::Running; + stale.started_at = Utc::now() - Duration::hours(1); + crate::db::workflows::insert_run(&conn, &stale).unwrap(); + + // Stale pending too. + let mut stale_pending = sample_run("stale-pending", "w1"); + stale_pending.status = RunStatus::Pending; + stale_pending.started_at = Utc::now() - Duration::hours(1); + crate::db::workflows::insert_run(&conn, &stale_pending).unwrap(); + + // Fresh: Running, started now → must NOT be touched (< cutoff). + let mut fresh = sample_run("fresh", "w1"); + fresh.status = RunStatus::Running; + crate::db::workflows::insert_run(&conn, &fresh).unwrap(); + + // Already terminal: Success, old → must NOT be touched. + let mut done = sample_run("done", "w1"); + done.status = RunStatus::Success; + done.started_at = Utc::now() - Duration::hours(2); + crate::db::workflows::insert_run(&conn, &done).unwrap(); + + let n = crate::db::workflows::reconcile_stale_runs(&conn, 30 * 60).unwrap(); + assert_eq!(n, 2, "the two stale in-flight runs are reconciled"); + + let by_id = |id: &str| crate::db::workflows::get_run(&conn, id).unwrap().unwrap(); + assert_eq!(by_id("stale").status, RunStatus::Interrupted); + assert!(by_id("stale").finished_at.is_some(), "Interrupted run gets a finished_at"); + assert_eq!(by_id("stale-pending").status, RunStatus::Interrupted); + assert_eq!(by_id("fresh").status, RunStatus::Running, "recent run untouched"); + assert_eq!(by_id("done").status, RunStatus::Success, "terminal run untouched"); + + // Cutoff 0 = the BOOT call (Copilot, PR #114): at boot there is no runner, + // so even a JUST-started zombie must flip — no 30-min lie window. + let n0 = crate::db::workflows::reconcile_stale_runs(&conn, 0).unwrap(); + assert_eq!(n0, 1, "the fresh zombie is reconciled at cutoff 0"); + assert_eq!(by_id("fresh").status, RunStatus::Interrupted); +} + #[test] fn list_runs_enriches_subworkflow_parent_provenance() { let conn = test_db(); diff --git a/backend/src/db/workflows.rs b/backend/src/db/workflows.rs index 40dc6560..e940ec7a 100644 --- a/backend/src/db/workflows.rs +++ b/backend/src/db/workflows.rs @@ -25,6 +25,7 @@ fn parse_run_status(s: &str) -> RunStatus { "Cancelled" => RunStatus::Cancelled, "WaitingApproval" => RunStatus::WaitingApproval, "StoppedByGuard" => RunStatus::StoppedByGuard, + "Interrupted" => RunStatus::Interrupted, _ => RunStatus::Pending, } } @@ -38,9 +39,30 @@ fn run_status_str(s: &RunStatus) -> &'static str { RunStatus::Cancelled => "Cancelled", RunStatus::WaitingApproval => "WaitingApproval", RunStatus::StoppedByGuard => "StoppedByGuard", + RunStatus::Interrupted => "Interrupted", } } +/// 0.8.11 (B5) — reconcile workflow runs left `Running`/`Pending` when the +/// backend process died mid-run (crash, container restart, `kill -9`, +/// cargo-watch reload). Without this they stay "Running" forever, poison the +/// active-runs badge, and make a cron's "did the last run succeed?" check read a +/// zombie as in-progress. Flips rows older than `stale_after_secs` to the +/// terminal `Interrupted` status (distinct from `Failed`). Mirrors +/// `audit_runs::reconcile_stale_runs`. Returns the number reconciled. +pub fn reconcile_stale_runs(conn: &Connection, stale_after_secs: i64) -> Result { + let cutoff = (Utc::now() - chrono::Duration::seconds(stale_after_secs)).to_rfc3339(); + let now_rfc = Utc::now().to_rfc3339(); + let affected = conn.execute( + "UPDATE workflow_runs SET + status = 'Interrupted', + finished_at = COALESCE(finished_at, ?2) + WHERE status IN ('Running', 'Pending') AND started_at < ?1", + params![cutoff, now_rfc], + )?; + Ok(affected as u64) +} + // ─── Workflows CRUD ───────────────────────────────────────────────────────── /// Prefix used for batch-placeholder workflow rows (Phase 1b). @@ -588,8 +610,33 @@ pub fn count_runs(conn: &Connection, workflow_id: &str) -> Result { Ok(count) } +/// Safety cap for the unpaginated `list_runs` — a workflow with thousands of +/// runs (a fast cron) would otherwise load every row WITH its full +/// `step_results_json` into memory on each page open. The UI folds at 10 and +/// paginates; 500 recent runs is far more than any view needs. Callers that +/// truly need everything use `list_runs_paginated` explicitly. (B7, 0.8.11) +pub const MAX_RUNS_UNPAGINATED: u32 = 500; + pub fn list_runs(conn: &Connection, workflow_id: &str) -> Result> { - list_runs_paginated(conn, workflow_id, None, None) + list_runs_paginated(conn, workflow_id, Some(MAX_RUNS_UNPAGINATED), None) +} + +/// 0.8.11 (B7) — auto-purge terminal workflow runs older than `days`. Preserves +/// any run still referenced as a parent by a retained child (so provenance +/// chains stay intact) and never touches non-terminal runs. Opt-in: the caller +/// only invokes this when `run_retention_days > 0`. Returns rows deleted. +pub fn purge_runs_older_than(conn: &Connection, days: u32) -> Result { + let n = conn.execute( + "DELETE FROM workflow_runs + WHERE status IN ('Success','Failed','Cancelled','StoppedByGuard','Interrupted') + AND finished_at IS NOT NULL + AND finished_at < datetime('now', ?1) + AND id NOT IN ( + SELECT parent_run_id FROM workflow_runs WHERE parent_run_id IS NOT NULL + )", + params![format!("-{} days", days)], + )?; + Ok(n) } /// True when at least one workflow run is currently `Running` or `Pending`. diff --git a/backend/src/main.rs b/backend/src/main.rs index f404d5ff..a1ac2dbd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -97,6 +97,30 @@ async fn main() -> anyhow::Result<()> { // setups override this via the env. std::env::set_var("KRONN_BACKEND_URL", format!("http://127.0.0.1:{}", port)); } + + // 0.8.11 — unify the API auth token across config.toml and the + // KRONN_AUTH_TOKEN env, in BOTH directions, so every layer agrees: + // • env → config: an operator who sets KRONN_AUTH_TOKEN (e.g. in + // docker-compose) enables auth without editing config.toml — the auth + // middleware reads `config.server.auth_token`, so mirror the env into it. + // • config → env: spawned children (the `kronn-internal` MCP sidecar, a + // grandchild via the agent CLI) read KRONN_AUTH_TOKEN and send + // `Authorization: Bearer` — without it an auth-enabled / LAN-exposed + // instance returned a silent 401 to its own sidecar. + let env_token = std::env::var("KRONN_AUTH_TOKEN") + .ok() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + if app_config.server.auth_token.is_none() { + if let Some(t) = env_token { + app_config.server.auth_token = Some(t); + } + } + if let Some(ref token) = app_config.server.auth_token { + if std::env::var("KRONN_AUTH_TOKEN").is_err() { + std::env::set_var("KRONN_AUTH_TOKEN", token); + } + } let max_agents = if app_config.server.max_concurrent_agents > 0 { app_config.server.max_concurrent_agents } else { @@ -110,6 +134,27 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("API authentication disabled — API is open to anyone on the network"); } + // Security guard (0.8.11) — refuse to start when the operator exposed the + // instance to the LAN but left the API unauthenticated. Secure-by-default: + // a fresh `docker compose up` publishes on 127.0.0.1 (see docker-compose.yml + // `KRONN_BIND`), so this never fires for the standard install. Exposing to + // the LAN then requires a token, auth, or an explicit risk acknowledgment. + let is_docker = std::env::var("KRONN_DATA_DIR").is_ok(); + let kronn_bind = std::env::var("KRONN_BIND").ok(); + let ack_insecure = std::env::var("KRONN_ALLOW_INSECURE_LAN") + .map(|v| matches!(v.trim(), "1" | "true" | "yes")) + .unwrap_or(false); + let exposed = kronn::core::net_expose::lan_exposed(is_docker, kronn_bind.as_deref(), &host); + if let Some(msg) = kronn::core::net_expose::insecure_lan_boot_error( + exposed, + app_config.server.auth_enabled, + app_config.server.auth_token.is_some(), + ack_insecure, + ) { + tracing::error!("{msg}"); + return Err(anyhow::anyhow!(msg)); + } + // Exactly ONE backend per data dir. Refuse to start if another instance // already holds the lock — prevents two processes (a stale one, or P2P peers // sharing a synced dir) racing on config.toml / the key / the DB. Held for @@ -229,6 +274,18 @@ async fn main() -> anyhow::Result<()> { Err(e) => tracing::warn!("Partial-response recovery failed: {}", e), } + // 0.8.11 (B7) — opt-in run retention. Only when the operator set + // `run_retention_days > 0`; parent runs referenced by a retained child are + // preserved by the query. Default 0 = keep all history (no surprise loss). + let retention_days = state.config.read().await.server.run_retention_days; + if retention_days > 0 { + match state.db.with_conn(move |conn| kronn::db::workflows::purge_runs_older_than(conn, retention_days)).await { + Ok(0) => {} + Ok(n) => tracing::info!("Run retention: purged {n} workflow run(s) older than {retention_days} days", ), + Err(e) => tracing::warn!("Run retention purge failed: {}", e), + } + } + // Reap abandoned MCP sessions (2026-06-08). `count_live_participants` is // presence-sticky — any `status='active'` session suppresses Kronn's // auto-response (no per-message staleness window, which had wrongly @@ -371,6 +428,13 @@ async fn main() -> anyhow::Result<()> { std::sync::Arc::new(kronn::core::learning_sweep::LearningSweep::new(state.db.clone())); tokio::spawn(async move { learning_sweep.start().await }); + // 0.8.11 (B6) — periodic DB backup (default every 24h, keep 7). Targets + // KRONN_BACKUP_DIR (a host-mounted dir outside the data volume) when set; + // KRONN_BACKUP_INTERVAL_HOURS=0 disables it entirely. + if let Some(backup_scheduler) = kronn::core::backup::BackupScheduler::from_env(state.db.clone()) { + tokio::spawn(async move { backup_scheduler.start().await }); + } + // Start WebSocket client manager (outbound connections to contacts) let ws_state = state.clone(); tokio::spawn(async move { kronn::core::ws_client::run(ws_state).await }); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index db9f4f44..ba7d906a 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -81,20 +81,58 @@ pub struct AiSearchResult { // ─── Generic API response wrappers ──────────────────────────────────────── +/// D11 (0.8.11) — machine-readable error category, so the frontend and the MCP +/// tools can branch on the KIND of failure instead of string-matching `error`. +/// Serialized as a stable snake_case string in `ApiResponse.error_code`. +/// Introduced incrementally: handlers opt in via `ApiResponse::err_coded`; +/// legacy `ApiResponse::err` leaves `error_code` unset (back-compatible). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ApiErrorCode { + /// The requested resource does not exist (→ HTTP 404 semantics). + NotFound, + /// The request is malformed / fails a business rule (→ 400/422). + Validation, + /// The request conflicts with current state (→ 409). + Conflict, + /// An unexpected server-side failure (→ 500). + Internal, +} + +impl ApiErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + ApiErrorCode::NotFound => "not_found", + ApiErrorCode::Validation => "validation", + ApiErrorCode::Conflict => "conflict", + ApiErrorCode::Internal => "internal", + } + } +} + #[derive(Debug, Serialize)] pub struct ApiResponse { pub success: bool, pub data: Option, pub error: Option, + /// D11 — stable error category (see `ApiErrorCode`). Omitted from the wire + /// when unset so legacy clients + untouched handlers are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, } impl ApiResponse { pub fn ok(data: T) -> Self { - Self { success: true, data: Some(data), error: None } + Self { success: true, data: Some(data), error: None, error_code: None } } pub fn err(msg: impl Into) -> Self { - Self { success: false, data: None, error: Some(msg.into()) } + Self { success: false, data: None, error: Some(msg.into()), error_code: None } + } + + /// Error response with a machine-readable category. Prefer this over `err` + /// in new/updated handlers so the frontend + MCP can branch on the kind. + pub fn err_coded(code: ApiErrorCode, msg: impl Into) -> Self { + Self { success: false, data: None, error: Some(msg.into()), error_code: Some(code.as_str().to_string()) } } } diff --git a/backend/src/models/setup.rs b/backend/src/models/setup.rs index 818aaddc..9ca904af 100644 --- a/backend/src/models/setup.rs +++ b/backend/src/models/setup.rs @@ -94,6 +94,21 @@ pub struct ServerConfig { #[serde(default)] #[ts(skip)] pub auth_strict_localhost: bool, + /// 0.8.11 (B6) — optional webhook (Slack/Teams/generic JSON) fired when a + /// scheduled/triggered run ends in a non-success terminal state + /// (Failed / Interrupted / StoppedByGuard). Lets an autonomous cron that + /// dies at 6am surface immediately instead of being discovered by opening + /// the UI. Empty/None = no notification. Also settable via + /// `KRONN_FAILURE_NOTIFY_URL`. + #[serde(default)] + pub failure_notify_url: Option, + /// 0.8.11 (B7) — auto-purge workflow runs older than N days at boot. + /// `0` (default) = DISABLED: never delete run history automatically (a fast + /// cron's run table is 76% of the DB, but silently dropping the user's + /// history is worse than size). Set to e.g. 90 to bound growth; parent runs + /// still referenced by a retained child are always preserved. + #[serde(default)] + pub run_retention_days: u32, /// Maximum concurrent agent processes (default: 5) #[serde(default = "default_max_agents")] pub max_concurrent_agents: usize, diff --git a/backend/src/models/tests.rs b/backend/src/models/tests.rs index 91dff5d1..9fac186e 100644 --- a/backend/src/models/tests.rs +++ b/backend/src/models/tests.rs @@ -687,3 +687,33 @@ fn typed_schema_round_trips_explicit_continue() { other => panic!("expected TypedSchema, got {other:?}"), } } + +// ─── D11 (0.8.11) — typed API error codes ───────────────────────────── +#[test] +fn api_error_code_strings_are_stable_snake_case() { + use super::ApiErrorCode::*; + assert_eq!(NotFound.as_str(), "not_found"); + assert_eq!(Validation.as_str(), "validation"); + assert_eq!(Conflict.as_str(), "conflict"); + assert_eq!(Internal.as_str(), "internal"); +} + +#[test] +fn api_response_err_coded_serializes_error_code_and_plain_err_omits_it() { + use super::{ApiResponse, ApiErrorCode}; + let coded = ApiResponse::<()>::err_coded(ApiErrorCode::NotFound, "Workflow not found"); + let j = serde_json::to_value(&coded).unwrap(); + assert_eq!(j["success"], false); + assert_eq!(j["error"], "Workflow not found"); + assert_eq!(j["error_code"], "not_found"); + + // Legacy plain err() must NOT emit error_code (back-compatible wire). + let plain = ApiResponse::<()>::err("boom"); + let jp = serde_json::to_value(&plain).unwrap(); + assert!(jp.get("error_code").is_none(), "plain err omits error_code, got: {jp}"); + + // ok() likewise has no error_code. + let ok = ApiResponse::ok(42); + let jo = serde_json::to_value(&ok).unwrap(); + assert!(jo.get("error_code").is_none()); +} diff --git a/backend/src/models/workflows.rs b/backend/src/models/workflows.rs index bb89f208..b0d04331 100644 --- a/backend/src/models/workflows.rs +++ b/backend/src/models/workflows.rs @@ -1069,6 +1069,12 @@ pub enum RunStatus { /// UX surfaces this with a shield icon (orange, not red) so users /// can tell a self-protected stop from a real failure. StoppedByGuard, + /// 0.8.11 — terminal state for a run that was in flight when the backend + /// process died (crash, container restart, `kill -9`, cargo-watch reload). + /// Reconciled at boot from a stuck `Running`/`Pending` row. Distinct from + /// `Failed` (the workflow didn't error — the host went away) so it doesn't + /// poison "last run succeeded" cron logic or read as a real failure. + Interrupted, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] diff --git a/backend/src/workflows/mod.rs b/backend/src/workflows/mod.rs index d0dd7f94..64a561e2 100644 --- a/backend/src/workflows/mod.rs +++ b/backend/src/workflows/mod.rs @@ -262,9 +262,12 @@ impl WorkflowEngine { // Execute in background tokio::spawn(async move { - if let Err(e) = runner::execute_run(state, &workflow, &mut run, &tokens, &agents, None, None, None).await { + if let Err(e) = runner::execute_run(state.clone(), &workflow, &mut run, &tokens, &agents, None, None, None).await { tracing::error!("Workflow run {} failed: {}", run.id, e); } + // B6 — surface a silently-failing scheduled/auto run via webhook. + // Best-effort: never affects the run. `run.status` is final here. + crate::core::run_notify::notify_if_failed(&state, &workflow, &run).await; }); Ok(()) @@ -288,6 +291,62 @@ fn heal_steps_in_place(steps: &mut [WorkflowStep]) -> Vec { mod tests { use super::*; + // ─── C9 (0.8.11) — cross-language step-type guard ───────────────────── + // + // The Rust runner dispatches on `StepType` at exhaustive `match` sites (no + // `_` arm), so the COMPILER already forces every variant to be handled at + // every site — adding a StepType can't silently skip the rollback/validation + // paths (the class of the 0.8.6 #59 incident). The one place with NO compile + // link to the enum is the Python MCP sidecar, which hand-lists the step types + // in `step_types_closed_set` (agents read it to author workflows). This test + // fails if that list drifts from the Rust enum — the enum side is exhaustive, + // so a new variant forces updating this test, which then fails until the + // sidecar is updated too. + #[test] + fn python_sidecar_step_types_match_rust_enum() { + // Exhaustive by construction: a new StepType variant is a compile error + // here until named. + fn variant_name(t: &StepType) -> &'static str { + match t { + StepType::Agent => "Agent", + StepType::ApiCall => "ApiCall", + StepType::BatchQuickPrompt => "BatchQuickPrompt", + StepType::Notify => "Notify", + StepType::Gate => "Gate", + StepType::Exec => "Exec", + StepType::BatchApiCall => "BatchApiCall", + StepType::JsonData => "JsonData", + StepType::SubWorkflow => "SubWorkflow", + } + } + let rust: std::collections::BTreeSet<&str> = [ + StepType::Agent, StepType::ApiCall, StepType::BatchQuickPrompt, + StepType::Notify, StepType::Gate, StepType::Exec, + StepType::BatchApiCall, StepType::JsonData, StepType::SubWorkflow, + ] + .iter() + .map(variant_name) + .collect(); + + let py_path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/disc-introspection-mcp.py"); + let py = std::fs::read_to_string(py_path).expect("read sidecar script"); + let start = py.find("\"step_types_closed_set\"").expect("step_types_closed_set present in sidecar"); + let arr = &py[start..]; + let open = arr.find('[').expect("["); + let close = arr[open..].find(']').expect("]") + open; + let py_types: std::collections::BTreeSet<&str> = arr[open + 1..close] + .split(',') + .map(|s| s.trim().trim_matches('"')) + .filter(|s| !s.is_empty()) + .collect(); + + assert_eq!( + rust, py_types, + "StepType enum and the Python sidecar's step_types_closed_set have drifted — \ + update scripts/disc-introspection-mcp.py to match the Rust enum." + ); + } + // ─── Healing pass (heal_steps_in_place) ────────────────────────────── // // End-to-end behavior of the boot healing pass, without the DB round- diff --git a/desktop/package.json b/desktop/package.json index b48de13e..7a453506 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "kronn-desktop", - "version": "0.8.10", + "version": "0.8.11", "private": true, "scripts": { "dev": "tauri dev", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 73d2d5b8..8ab3170d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2368,7 +2368,7 @@ dependencies = [ [[package]] name = "kronn" -version = "0.8.10" +version = "0.8.11" dependencies = [ "aes-gcm", "anyhow", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "kronn-desktop" -version = "0.8.10" +version = "0.8.11" dependencies = [ "anyhow", "axum", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 727b840f..c446329f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kronn-desktop" -version = "0.8.10" +version = "0.8.11" 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 bd2c253a..d660cc89 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.10", + "version": "0.8.11", "identifier": "com.kronn.desktop", "build": { "frontendDist": "../../frontend/dist", diff --git a/docker-compose.yml b/docker-compose.yml index 60e213fe..3e6bf711 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,10 @@ services: # exec fails on the broken symlink target # (TD-20260507-claude-symlink-not-resolvable). - ${HOME}/.local/share:${HOME}/.local/share:ro + # 0.8.11 (B6) — uncomment + set KRONN_BACKUP_DIR=/backups to store scheduled + # DB backups on the HOST, outside the data volume (so a lost volume doesn't + # lose the backups too). Point the left side wherever you keep backups. + # - ${KRONN_BACKUP_HOST_DIR:-./kronn-backups}:/backups:rw - npm-cache:/home/kronn/.npm # Persistent npm cache for npx agent execution - uv-cache:/home/kronn/.cache/uv # Isolated uv cache (named volume — see TD-20260429-uv-cache-uid-mismatch: bind-mounting ~/.cache/uv left root-owned files on host pre-APP_UID, breaking host `uvx` for MCP servers in CC CLI / Codex / Gemini) - uv-tools:/home/kronn/.local/share/uv # Persistent uv tool installs (CloudWatch, Atlassian MCP servers) @@ -144,6 +148,21 @@ services: - GITHUB_TOKEN=${GITHUB_TOKEN:-} - GH_TOKEN=${GH_TOKEN:-${GITHUB_TOKEN:-}} - OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434} + # 0.8.11 — LAN exposure is opt-in. The gateway/frontend host ports below + # bind 127.0.0.1 by default; set KRONN_BIND=0.0.0.0 (e.g. for a WSL + # backend reached from a Mac frontend) to publish on the LAN. When you do, + # the backend REFUSES to start unauthenticated — set KRONN_AUTH_TOKEN, or + # KRONN_ALLOW_INSECURE_LAN=1 to keep it open on a trusted home network. + - KRONN_BIND=${KRONN_BIND:-127.0.0.1} + - KRONN_ALLOW_INSECURE_LAN=${KRONN_ALLOW_INSECURE_LAN:-} + - KRONN_AUTH_TOKEN=${KRONN_AUTH_TOKEN:-} + # 0.8.11 (B6) — failure webhook (Slack/Teams/generic) + scheduled backups. + # A backup dir OUTSIDE the data volume needs a host bind-mount (see the + # commented volume below); set KRONN_BACKUP_DIR to /backups to use it. + - KRONN_FAILURE_NOTIFY_URL=${KRONN_FAILURE_NOTIFY_URL:-} + - KRONN_BACKUP_DIR=${KRONN_BACKUP_DIR:-} + - KRONN_BACKUP_INTERVAL_HOURS=${KRONN_BACKUP_INTERVAL_HOURS:-24} + - KRONN_BACKUP_KEEP=${KRONN_BACKUP_KEEP:-7} # RUST_LOG: if unset, the backend reads `config.server.debug_mode` to # pick a sensible default (info vs debug). Set `KRONN_RUST_LOG` in the # env (or via `./kronn start --debug` / `make start DEBUG=1`) to @@ -176,7 +195,8 @@ services: context: ./frontend dockerfile: Dockerfile ports: - - "3141:80" + # 0.8.11 — loopback by default; KRONN_BIND=0.0.0.0 opts into LAN exposure. + - "${KRONN_BIND:-127.0.0.1}:3141:80" deploy: resources: limits: @@ -202,7 +222,9 @@ services: memory: 128M cpus: '0.25' ports: - - "3140:80" + # 0.8.11 — main entry point. Loopback by default (secure out-of-the-box); + # set KRONN_BIND=0.0.0.0 to expose on the LAN (backend then requires auth). + - "${KRONN_BIND:-127.0.0.1}:3140:80" volumes: - ./.docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: diff --git a/docs/decisions.md b/docs/decisions.md index 66d7dbef..ca9fcad8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -30,3 +30,16 @@ This file captures **intentional choices** — patterns that look unusual but ar | Claude scope-aware host write — `is_global` and `project_ids` route to the right Claude scope | The first Phase-3 implementation had `host_sync` as an independent dimension (3 modes None/GlobalOnly/MirrorAll) that overrode the user's project_ids selection. The user spotted the inconsistency: "if I scope to APP_ANDROID, why does Claude write top-level?". Fixed by routing `is_global=true` → top-level mcpServers (Claude scope `user`), `is_global=false + project_ids` → `projects[].mcpServers` for each project (Claude scope `local`). Other CLIs lack a native local scope so they stay top-level — visible asymmetry, not silent. Migration 038 collapsed `MirrorAll` to `is_global=true + host_sync='GlobalOnly'`. | Do NOT add a third "scope" dimension orthogonal to `is_global` + `project_ids`. The CLI sync MUST follow the Kronn scope. If a future CLI gains a per-project scope (Codex, Gemini), route to it the same way Claude is routed today. | `backend/src/core/mcp_scanner.rs::sync_claude_global_config` (Phase-3 refactor), migration 038, `frontend/src/components/HostSyncPreview.tsx` (renders the asymmetry), `docs/AGENTS.md § Host MCP sync` | | `_kronn` marker on every Kronn-written entry, hash fallback for ownership | Initial design used hash-only ownership detection. MCP Expert flagged it as fragile: hash breaks on JSON reformatting, manual env edits, key reorders → orphan cleanup misclassifies + orphans accumulate forever. Switched to explicit marker `{managed: true, config_id: }` on every entry Kronn writes. Hash kept as fallback for entries written by older Kronn versions or copy-pasted by the user. | Do NOT remove the `_kronn` marker emit when adding a new sync target — orphan cleanup walks the marker, not the hash. Treat the marker as the canonical ownership signal and hash as best-effort legacy detection. | `backend/src/core/mcp_scanner.rs::build_kronn_managed_json_entry` + `is_kronn_managed` + `drop_all_kronn_entries`, plan v2 review (MCP Expert P0) | | Windows: resolve bare program names via `which` in `cmd.rs`, don't expect CreateProcess to find `.cmd`/`.bat` | `Command::new("npx")` fails on Windows even when Node.js is installed because Win32 CreateProcess refuses to execute `.cmd`/`.bat` wrappers when called by their bare name — only `.exe` works without an extension. Reported by a Windows user (npm-installed Node.js, npx accessible from PowerShell, but Kronn raised "Spawn failed for npx: program not found"). Fix: `async_cmd`/`sync_cmd` resolve bare names through `which::which` on Windows before passing to `Command::new`, returning the fully-qualified `.cmd` path. Skipped for paths that already contain `\`, `/`, or an extension. | Do NOT bypass `cmd::async_cmd`/`cmd::sync_cmd` on Windows — using raw `Command::new("npx")` will reintroduce the bug. The cross-platform contract is: pass a bare name, the helper handles the rest. | `backend/src/core/cmd.rs::async_cmd` + `sync_cmd` + `resolve_windows_program` | + +## Frozen surfaces (until 1.0) — 0.8.11 scope decision + +Kronn's thesis is **de-agentification**: orchestrate real CLI agents and burn tokens only where they add value (zero-token API/Exec/JsonData steps, local models, `[src:]`-verified context). Several surfaces predate or sit outside that thesis. As of the 2026-07 audit they are **FROZEN, not removed** — kept working, but not extended — so a single maintainer can stabilise the core (and the coming continual-learning strate) without growing the surface to defend. + +| Frozen surface | Status | Why frozen | +|----------------|--------|-----------| +| Voice (TTS/STT — Piper/Whisper WASM) | Works, no new work | Doesn't serve de-agentification; local-only design is fine as-is | +| Secret themes (matrix/gotham/sakura) | Works, no new work | Cosmetic; each new theme is more `tokens.css` surface + drift risk | +| P2P / contacts / rooms | Works where it works | Can't work in native (see `TD-20260629-p2p-native-binding`); Tauri+Docker cover the real single-user need | +| Mobile | As-is | No responsive investment planned before 1.0 | + +**Do NOT** add features to these areas before 1.0 without an explicit scope decision. New effort goes to the core (workflows, local models, MCP surface) and, once the socle is clean, continual learning. Reassess at 1.0. diff --git a/docs/operations/mcp-servers/kronn-internal.md b/docs/operations/mcp-servers/kronn-internal.md index 538443fb..5b8333b1 100644 --- a/docs/operations/mcp-servers/kronn-internal.md +++ b/docs/operations/mcp-servers/kronn-internal.md @@ -2,7 +2,7 @@ **Server:** `kronn-internal` (Python stdio bridge — `backend/scripts/disc-introspection-mcp.py`) **Source:** This repo. Auto-injected by Kronn into every supported CLI's MCP config (`.mcp.json`, `~/.codex/config.toml`, `.gemini/settings.json`, `.kiro/settings/mcp.json`, `.vibe/config.toml`). -**Auth:** None (local stdio). The bridge talks to the Kronn backend over `KRONN_BACKEND_URL` (default `http://127.0.0.1:3140`). +**Auth:** stdio itself is unauthenticated (local pipe), but the bridge authenticates to the Kronn backend over `KRONN_BACKEND_URL` (default `http://127.0.0.1:3140`): when the backend has a token configured, it exports `KRONN_AUTH_TOKEN` into the process env, the sidecar inherits it and sends `Authorization: Bearer ` on every call. On a loopback-only instance the backend's local-trust bypass makes the token optional; on a LAN-exposed instance (e.g. WSL backend / Mac frontend) it is required — otherwise the sidecar's own calls get a silent 401. `[src: file: backend/scripts/disc-introspection-mcp.py:1970-1994]` `[src: file: backend/src/main.rs:102-115]` ## What it does diff --git a/docs/tech-debt/TD-20260510-codex-upstream-issue-draft.md b/docs/tech-debt/TD-20260510-codex-upstream-issue-draft.md index c47741b0..66afd537 100644 --- a/docs/tech-debt/TD-20260510-codex-upstream-issue-draft.md +++ b/docs/tech-debt/TD-20260510-codex-upstream-issue-draft.md @@ -1,7 +1,7 @@ - **ID**: TD-20260510-codex-upstream-issue-draft - **Area**: External / upstream - **Severity**: Low (dependency on TD-20260510-codex-mcp-sandbox-block) -- **Status**: 🟢 Draft prepared — needs to be filed at https://github.com/openai/codex-cli +- **Status**: 🟢 Draft prepared — READY TO FILE at https://github.com/openai/codex-cli (0.8.11 audit follow-up). This is an UPSTREAM post, not internal code — it needs the maintainer's GitHub account to submit; once filed, close this TD. No further code action on the Kronn side. ## Purpose diff --git a/docs/tech-debt/TD-20260629-import-downgrade-wipe.md b/docs/tech-debt/TD-20260629-import-downgrade-wipe.md index d1afbec8..2e4894f7 100644 --- a/docs/tech-debt/TD-20260629-import-downgrade-wipe.md +++ b/docs/tech-debt/TD-20260629-import-downgrade-wipe.md @@ -13,6 +13,7 @@ - **Don't clear tables absent from the payload** — only `DELETE` the tables the export actually carries (selective clear), so an old export can't wipe newer data. - Longer term: consider **merge/upsert** import instead of clear-then-insert. - **Next step**: create ticket. +- **Status**: 🟢 RESOLVED in 0.8.10 (verified 0.8.11). `do_import_db` (`backend/src/api/setup.rs`) now clears SELECTIVELY — only the tables the payload actually carries (`import_clear_statements`) — and warns loudly on a downgrade import (`data.version < CURRENT_EXPORT_VERSION`). A v3 export onto a v4 box no longer wipes `quick_apis`/`learnings`. The destructive endpoints are also auth-gated even when app-auth is off (see `DESTRUCTIVE_PATHS` in `lib.rs`). Closing. ## Notes diff --git a/docs/tech-debt/TD-20260701-typegen-generated-aggregate.md b/docs/tech-debt/TD-20260701-typegen-generated-aggregate.md index 9bf57f53..7ddfcdac 100644 --- a/docs/tech-debt/TD-20260701-typegen-generated-aggregate.md +++ b/docs/tech-debt/TD-20260701-typegen-generated-aggregate.md @@ -6,3 +6,9 @@ - **Where (pointers)**: `Makefile` (`typegen:` target ~234), `backend/bindings/*.ts` (ts-rs output), `frontend/src/types/generated.ts` (committed aggregate), `backend/Cargo.toml:79` (ts-rs 12). - **Suggested direction (non-binding)**: Add a `scripts/assemble-generated-types.mjs` wired into `make typegen` after the cargo step that: (1) reads `backend/bindings/*.ts`, strips per-file headers + `import` lines, `bigint`→`number`; (2) converts object `export type` → `export interface`; (3) marks a field optional (`?`) when the corresponding Rust field has `#[serde(default)]` / `skip_serializing_if` — parsed from `backend/src/models/*.rs`. Validate by running `tsc` over the whole frontend (the oracle). Alternatively migrate the frontend to tolerate required fields (bigger app-code churn). As an interim, new fields are hand-added to `generated.ts` matching the existing interface style (source of truth = the Rust model). - **Next step**: create ticket +- **Status (0.8.11, C8)**: 🟡 PARTIALLY ADDRESSED — the recurring pain (a new/changed Rust field silently missing from `generated.ts`) is now CAUGHT, not silent: + - `frontend/scripts/check-types-drift.mjs` — field-level guard: for every type `generated.ts` declares, fails if the ts-rs binding has a field the aggregate lacks (field NAMES only, so the deliberate bigint/optional simplifications never trip it). Wired into `make typegen` AND CI (build-frontend job, after regenerating bindings). + - The 32 real drift fields across 10 frontend-used types (ServerConfig had only host+port!, WorkflowRun.state, WorkflowExportEnvelope.referenced_workflows, on_timeout, …) were back-filled; 10 test mocks updated for the now-present `Discussion.pin_first_message`. tsc + full suite green. + - `frontend/scripts/assemble-generated-types.mjs` — a full-regen assembler (strips headers/imports, `bigint`→`number`, keeps ts-rs `export type`). NOT wired to overwrite: a faithful wholesale regen still needs the serde-default→optional transform (the ~189-call-site churn described above) — that remains the dedicated migration. The guard makes it safe to defer. + - Misleading "AUTO-GENERATED by ts-rs" header replaced with an accurate "SEMI-GENERATED + how to sync" banner. + - REMAINING: the full auto-regen (retire the hand-maintained aggregate) — do the serde-default→optional transform + adapt the frontend. No longer urgent now that drift is guarded. diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..92ee7cbd --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,5 @@ +# 0.8.11 — skip the onnxruntime-node CUDA GPU download in its postinstall +# (linux-x64 only; never used — browser app → onnxruntime-web, CPU binaries +# bundled; api.nuget.org times out on CI runners). Belt-and-braces with the +# ONNXRUNTIME_NODE_INSTALL=skip env set in ci-test.yml and frontend/Dockerfile. +onnxruntime-node-install=skip diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 45556e7e..95d819b7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,7 +15,12 @@ RUN corepack enable # install-script deps). Missing it triggers ERR_PNPM_LOCKFILE_CONFIG_MISMATCH # on `--frozen-lockfile`. The `*` glob keeps the COPY tolerant if the file # isn't present in older branches. -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./ +# 0.8.11 — onnxruntime-node's postinstall downloads CUDA GPU binaries from +# api.nuget.org on linux-x64: never used (browser bundle → onnxruntime-web, +# CPU binaries are bundled in the npm package) and the download regularly +# times out. Skip it (documented knob of the package's install script). +ENV ONNXRUNTIME_NODE_INSTALL=skip RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile diff --git a/frontend/package.json b/frontend/package.json index 89c71417..3c08feb6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kronn-frontend", - "version": "0.8.10", + "version": "0.8.11", "private": true, "type": "module", "engines": { @@ -58,10 +58,5 @@ "vite": "^8.0.11", "vitest": "^4.1.5" }, - "packageManager": "pnpm@11.0.9", - "pnpm": { - "neverBuiltDependencies": [ - "onnxruntime-node" - ] - } + "packageManager": "pnpm@11.0.9" } diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 28f59e12..3c88feee 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -8,6 +8,12 @@ # • onnxruntime-node: native ORT runtime via vits-web (TTS). # • protobufjs: postinstall codegen for the gRPC client transitively # pulled by onnxruntime. +# NOTE (0.8.11): onnxruntime-node's postinstall additionally tries to +# download CUDA GPU binaries from api.nuget.org on linux-x64 — never +# used (browser app → onnxruntime-web; CPU binaries are bundled) and +# the download times out on GitHub runners (THE recurring CI flake). +# It is skipped via ONNXRUNTIME_NODE_INSTALL=skip in ci-test.yml (env) +# and frontend/Dockerfile — the build itself stays allowed here. # Re-vet any addition — pnpm 11 errors out (ERR_PNPM_IGNORED_BUILDS, # exit 1) on `pnpm install --frozen-lockfile` if a build-script dep # is missing from this list, so CI catches drift. @@ -31,3 +37,4 @@ overrides: flatted: '>=3.4.2' brace-expansion: '>=5.0.5' protobufjs: '>=7.5.5' + diff --git a/frontend/scripts/assemble-generated-types.mjs b/frontend/scripts/assemble-generated-types.mjs new file mode 100644 index 00000000..76028b22 --- /dev/null +++ b/frontend/scripts/assemble-generated-types.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// C8 (0.8.11) — assemble `src/types/generated.ts` from the per-type ts-rs +// bindings in `backend/bindings/`. `make typegen` runs `cargo test +// export_bindings` (ts-rs writes one file per type) then this script, which: +// • strips each file's `// generated by ts-rs` header + `import type …` lines +// (in one aggregate file, cross-type refs resolve by name, no imports), +// • keeps ts-rs's native `export type X = …` verbatim (object types, string +// unions, AND tagged unions `{…} | {…}` — converting the latter to an +// interface would be malformed; `export type` + `import type` work for all), +// • concatenates in a STABLE (name-sorted) order so the output is +// deterministic — a CI job diffs the regenerated file to catch drift. +// +// Historically `generated.ts` was hand-maintained and drifted from the Rust +// models (86 missing types at the 2026-07 audit). This closes that gap. +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BINDINGS = join(HERE, '..', '..', 'backend', 'bindings'); +const OUT = join(HERE, '..', 'src', 'types', 'generated.ts'); + +function walk(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) out.push(...walk(p)); + else if (name.endsWith('.ts')) out.push(p); + } + return out; +} + +const files = walk(BINDINGS); +// Sort by type name (basename) for deterministic output. +files.sort((a, b) => basename(a).localeCompare(basename(b))); + +const seen = new Set(); +const blocks = []; +for (const f of files) { + let src = readFileSync(f, 'utf8'); + src = src + .replace(/^\/\/ This file was generated by \[ts-rs\].*$/m, '') + .replace(/^import type .*;\s*$/gm, '') + .trim(); + if (!src) continue; + const m = src.match(/export type (\w+) =/); + const name = m ? m[1] : basename(f, '.ts'); + if (seen.has(name)) continue; + seen.add(name); + // Normalize ts-rs's `bigint` (Rust u64/i64/usize) → `number`. Kronn's such + // values (token counts, durations-ms, byte sizes, sequence numbers) are all + // well under 2^53, the whole frontend does `number` arithmetic on them, and + // JSON.parse yields `number` anyway (not BigInt). Matches the convention the + // hand-maintained file used before this generator existed. + src = src.replace(/\bbigint\b/g, 'number'); + blocks.push(src); +} + +const header = `// ╔═══════════════════════════════════════════════════════════════════════════╗ +// ║ AUTO-GENERATED — do not edit manually. ║ +// ║ Source: ts-rs bindings in backend/bindings/ (\`#[derive(TS)]\` on Rust ║ +// ║ models). Assembled by frontend/scripts/assemble-generated-types.mjs. ║ +// ║ Regenerate: \`make typegen\`. CI fails if this file drifts from the models. ║ +// ╚═══════════════════════════════════════════════════════════════════════════╝ +`; + +writeFileSync(OUT, header + '\n' + blocks.join('\n\n') + '\n'); +console.log(`assembled ${blocks.length} types → ${OUT}`); diff --git a/frontend/scripts/check-types-drift.mjs b/frontend/scripts/check-types-drift.mjs new file mode 100644 index 00000000..63239082 --- /dev/null +++ b/frontend/scripts/check-types-drift.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// C8 (0.8.11) — FIELD-level anti-drift guard for the hand-curated +// `src/types/generated.ts`. +// +// `generated.ts` is (still) hand-maintained with deliberate frontend-friendly +// simplifications (u64→number, serde-default→optional, JsonValue widening); a +// faithful ts-rs regen is a separate migration (see assemble-generated-types.mjs +// + TD-20260701). Many Rust types are backend-only and INTENTIONALLY absent from +// the aggregate, so a missing-whole-type check is all false positives. +// +// The REAL recurring pain (hit 4× in 0.8.x) is: a Rust type the frontend DOES +// use gains a field, and generated.ts is never updated → the field is silently +// invisible to the UI (parent_run_id, on_timeout, Interrupted…). So this guard, +// for every type that generated.ts ALREADY declares, compares its field NAMES +// against the ts-rs binding and fails if the binding has fields the aggregate is +// missing. Field NAMES only (not types) — so the deliberate bigint/optional +// simplifications never trip it. Run after `cargo test export_bindings`. +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BINDINGS = join(HERE, '..', '..', 'backend', 'bindings'); +const GENERATED = join(HERE, '..', 'src', 'types', 'generated.ts'); + +if (!existsSync(BINDINGS)) { + console.error(`[types-drift] bindings dir missing — run: (cd backend && cargo test export_bindings)`); + process.exit(2); +} + +function walk(dir) { + const out = []; + for (const n of readdirSync(dir)) { + const p = join(dir, n); + if (statSync(p).isDirectory()) out.push(...walk(p)); + else if (n.endsWith('.ts')) out.push(p); + } + return out; +} + +/** Strip `/* … *​/` block comments + `// …` line comments so field scans don't + * pick up doc-comment prose. */ +function stripComments(s) { + return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +/** Top-level field names in an object-type body. Handles nested objects by + * tracking brace depth — only depth-1 `name:` / `name?:` are fields. */ +function fieldNames(body) { + const clean = stripComments(body); + const names = new Set(); + let depth = 0; + // Walk token by token, recording identifiers at depth 1 immediately before a `:`. + const re = /([{}])|(\b[a-zA-Z_][a-zA-Z0-9_]*)\s*\??\s*:/g; + let m; + while ((m = re.exec(clean))) { + if (m[1] === '{') depth++; + else if (m[1] === '}') depth--; + else if (m[2] && depth === 1) names.add(m[2]); + } + return names; +} + +/** Extract the object body `{ … }` for `export (type|interface) Name` from src, + * or null if it isn't a single object type (union / alias). */ +function objectBody(src, name) { + const re = new RegExp(`export (?:interface|type) ${name}\\b[^{]*(\\{)`, 'm'); + const m = re.exec(src); + if (!m) return null; + // Balance braces from the opening `{`. + let i = m.index + m[0].length - 1; + let depth = 0; + const start = i; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(start, i + 1); } + } + return null; +} + +const generated = readFileSync(GENERATED, 'utf8'); +const declared = new Set([...generated.matchAll(/export (?:interface|type) (\w+)/g)].map((m) => m[1])); + +const drift = []; +for (const f of walk(BINDINGS)) { + const name = basename(f, '.ts'); + if (!declared.has(name)) continue; // backend-only type not in the aggregate → out of scope + const bindingSrc = readFileSync(f, 'utf8'); + const bindingBody = objectBody(bindingSrc, name); + const genBody = objectBody(generated, name); + if (!bindingBody || !genBody) continue; // union/alias — no field comparison + const want = fieldNames(bindingBody); + const have = fieldNames(genBody); + const missing = [...want].filter((fld) => !have.has(fld)); + if (missing.length) drift.push({ name, missing }); +} + +if (drift.length) { + console.error(`[types-drift] ${drift.length} frontend type(s) are MISSING fields present in the Rust model:`); + for (const d of drift) console.error(` ${d.name}: ${d.missing.join(', ')}`); + console.error(`\nAdd the field(s) to src/types/generated.ts (see backend/bindings/.ts).`); + process.exit(1); +} +console.log(`[types-drift] OK — no field drift on the ${declared.size} frontend-declared types.`); diff --git a/frontend/src/components/MermaidDiagram.tsx b/frontend/src/components/MermaidDiagram.tsx index 16bd2673..da05c803 100644 --- a/frontend/src/components/MermaidDiagram.tsx +++ b/frontend/src/components/MermaidDiagram.tsx @@ -30,6 +30,7 @@ interface MermaidDiagramProps { * subsequent renders don't re-import). The init call sets a neutral * theme that respects Kronn's `--kr-bg-*` palette. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- `typeof import()` is the idiomatic way to type a lazily-imported module without eagerly loading it. let mermaidPromise: Promise | null = null; function getMermaid() { if (!mermaidPromise) { @@ -190,7 +191,7 @@ function MermaidDiagramImpl({ source }: MermaidDiagramProps) { } -${svgMarkupRef.current} `); win.document.close(); }; diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index 0c1433a9..3b976cfa 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -802,14 +802,14 @@ const LargeMessageFallback = memo(({ content }: { content: string }) => { }); export const MarkdownContent = memo(({ content, discussionId }: { content: string; discussionId?: string }) => { - // Guard against multi-MB messages crashing the tab — see MAX_MARKDOWN_CHARS. - if (content.length > MAX_MARKDOWN_CHARS) { - return ; - } // Override the `pre` handler when we have a discussion id: fenced // blocks tagged `kronn-doc-preview` get replaced with the DocPreview // component (sandboxed iframe + export buttons). Everything else // renders through the shared mdComponents table above. + // NOTE: this useMemo runs UNCONDITIONALLY (before the big-content guard + // below) — a hook after an early return violates rules-of-hooks. It only + // builds the components table, so running it for huge content is free; the + // expensive markdown parse is gated by the guard. const components = useMemo(() => { if (!discussionId) return mdComponents; return { @@ -864,6 +864,12 @@ export const MarkdownContent = memo(({ content, discussionId }: { content: strin }; }, [discussionId]); + // Guard against multi-MB messages crashing the tab — see MAX_MARKDOWN_CHARS. + // Placed AFTER all hooks (the useMemo above) to satisfy rules-of-hooks. + if (content.length > MAX_MARKDOWN_CHARS) { + return ; + } + return (
diff --git a/frontend/src/components/__tests__/ChatHeader.pendingFiles.test.tsx b/frontend/src/components/__tests__/ChatHeader.pendingFiles.test.tsx index d0d826bf..cad940b0 100644 --- a/frontend/src/components/__tests__/ChatHeader.pendingFiles.test.tsx +++ b/frontend/src/components/__tests__/ChatHeader.pendingFiles.test.tsx @@ -32,7 +32,7 @@ function makeDiscussion(overrides: Partial = {}): Discussion { messages: [], message_count: 0, non_system_message_count: 0, archived: false, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Isolated', worktree_branch: 'kronn/test-branch', workspace_path: '/tmp/worktree', diff --git a/frontend/src/components/__tests__/ChatHeader.profileEditor.test.tsx b/frontend/src/components/__tests__/ChatHeader.profileEditor.test.tsx index 8a640cb7..018072a9 100644 --- a/frontend/src/components/__tests__/ChatHeader.profileEditor.test.tsx +++ b/frontend/src/components/__tests__/ChatHeader.profileEditor.test.tsx @@ -34,7 +34,7 @@ function makeDiscussion(over: Partial = {}): Discussion { messages: [], message_count: 0, non_system_message_count: 0, archived: false, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Direct', workspace_path: null, created_at: '2026-01-01T00:00:00Z', diff --git a/frontend/src/components/__tests__/DiscussionSidebar.grouping.test.tsx b/frontend/src/components/__tests__/DiscussionSidebar.grouping.test.tsx index 23274a92..3db30705 100644 --- a/frontend/src/components/__tests__/DiscussionSidebar.grouping.test.tsx +++ b/frontend/src/components/__tests__/DiscussionSidebar.grouping.test.tsx @@ -69,7 +69,7 @@ const mkDisc = (over: Partial & { id: string }): Discussion => ({ message_count: 0, non_system_message_count: 0, archived: false, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-05-15T10:00:00Z', updated_at: '2026-05-15T10:00:00Z', @@ -230,9 +230,9 @@ describe('DiscussionSidebar — grouping', () => { describe('DiscussionSidebar — pinned / favorites section', () => { it('renders pinned discs cross-project, sorted by updated_at desc', async () => { const discussions = [ - mkDisc({ id: 'p-old', pinned: true, title: 'Pinned older', updated_at: '2026-05-10T00:00:00Z' }), - mkDisc({ id: 'p-new', pinned: true, title: 'Pinned newer', updated_at: '2026-05-20T00:00:00Z' }), - mkDisc({ id: 'reg', pinned: false, title: 'Regular' }), + mkDisc({ id: 'p-old', pinned: true, pin_first_message: false, title: 'Pinned older', updated_at: '2026-05-10T00:00:00Z' }), + mkDisc({ id: 'p-new', pinned: true, pin_first_message: false, title: 'Pinned newer', updated_at: '2026-05-20T00:00:00Z' }), + mkDisc({ id: 'reg', pinned: false, pin_first_message: false, title: 'Regular' }), ]; render(); await waitFor(() => expect(projectsApi.discSources).toHaveBeenCalled()); @@ -255,7 +255,7 @@ describe('DiscussionSidebar — pinned / favorites section', () => { }); it('no favorites header when nothing is pinned', async () => { - const discussions = [mkDisc({ id: 'reg', pinned: false, title: 'Regular' })]; + const discussions = [mkDisc({ id: 'reg', pinned: false, pin_first_message: false, title: 'Regular' })]; render(); await waitFor(() => expect(projectsApi.discSources).toHaveBeenCalled()); expect(screen.queryByText('disc.favorites')).toBeNull(); @@ -320,7 +320,7 @@ describe('DiscussionSidebar — search filter', () => { const projects = [mkProject('p-acme', 'AcmeRepo', 'git@github.com:acme-org/AcmeRepo.git')]; const discussions = [ // A favorite that does NOT match the query — must disappear during search. - mkDisc({ id: 'fav1', pinned: true, title: 'Pinned unrelated note' }), + mkDisc({ id: 'fav1', pinned: true, pin_first_message: false, title: 'Pinned unrelated note' }), // A project disc that does NOT match — its folder must vanish entirely. mkDisc({ id: 'pj1', project_id: 'p-acme', title: 'AcmeRepo chore' }), // The one we're hunting for. diff --git a/frontend/src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx b/frontend/src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx index 9c9eeb31..0e2e2d7e 100644 --- a/frontend/src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx +++ b/frontend/src/components/__tests__/DiscussionSidebar.markAllRead.test.tsx @@ -56,7 +56,7 @@ const mkDisc = (id: string, msgCount: number, archived = false): Discussion => ( messages: [], message_count: msgCount, non_system_message_count: msgCount, archived, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', diff --git a/frontend/src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx b/frontend/src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx index 848dc04b..d64cb95e 100644 --- a/frontend/src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx +++ b/frontend/src/components/__tests__/DiscussionSidebar.sourceBadge.test.tsx @@ -60,7 +60,7 @@ const mkDisc = (id: string, title: string): Discussion => ({ messages: [], message_count: 0, non_system_message_count: 0, archived: false, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-05-15T10:00:00Z', updated_at: '2026-05-15T10:00:00Z', diff --git a/frontend/src/components/__tests__/MessageBubble.seedToggle.test.tsx b/frontend/src/components/__tests__/MessageBubble.seedToggle.test.tsx index dc727f17..5989a691 100644 --- a/frontend/src/components/__tests__/MessageBubble.seedToggle.test.tsx +++ b/frontend/src/components/__tests__/MessageBubble.seedToggle.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; import { splitMessageSeed } from '../MessageBubble'; describe('splitMessageSeed (0.8.5)', () => { @@ -99,8 +100,7 @@ describe('Kronn seed toggle (via splitMessageSeed + manual render)', () => { // Smoke render of a minimal disclosure that mirrors the production // KronnSeedToggle, so the assertion stays decoupled from styling. function Toggle({ payload }: { payload: string }) { - const React = require('react') as typeof import('react'); - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); return (
diff --git a/frontend/src/components/__tests__/TestModeBanner.test.tsx b/frontend/src/components/__tests__/TestModeBanner.test.tsx index 333b90b8..0e3a0b25 100644 --- a/frontend/src/components/__tests__/TestModeBanner.test.tsx +++ b/frontend/src/components/__tests__/TestModeBanner.test.tsx @@ -17,7 +17,7 @@ function disc(overrides: Partial = {}): Discussion { id: 'd-1', project_id: 'p-1', title: 'Switch theme tokens', agent: 'ClaudeCode' as any, language: 'en', participants: ['ClaudeCode' as any], messages: [], message_count: 0, non_system_message_count: 0, - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Isolated', worktree_branch: 'kronn/switch-theme', test_mode_restore_branch: 'main', diff --git a/frontend/src/components/tour/TourOverlay.tsx b/frontend/src/components/tour/TourOverlay.tsx index 50c8d934..d727d2f1 100644 --- a/frontend/src/components/tour/TourOverlay.tsx +++ b/frontend/src/components/tour/TourOverlay.tsx @@ -61,8 +61,8 @@ export function TourOverlay() { aria-label={t(currentStep.titleKey)} > {/* Group label (act name) */} - {currentStep.group && ( -
{currentStep.group}
+ {currentStep.groupKey && ( +
{t(currentStep.groupKey)}
)} {/* Step counter */} diff --git a/frontend/src/components/tour/tourSteps.ts b/frontend/src/components/tour/tourSteps.ts index 6aa656f8..add1fe35 100644 --- a/frontend/src/components/tour/tourSteps.ts +++ b/frontend/src/components/tour/tourSteps.ts @@ -16,7 +16,7 @@ export interface TourStep { descKey: string; position?: 'top' | 'bottom' | 'left' | 'right'; waitForClick?: boolean; - group?: string; + groupKey?: string; pulse?: boolean; beforeStep?: () => void; afterStep?: () => void; @@ -60,7 +60,7 @@ export const TOUR_STEPS: TourStep[] = [ selector: null, titleKey: 'tour.welcome.title', descKey: 'tour.welcome.desc', - group: 'Bienvenue', + groupKey: 'tour.group.welcome', }, // ── Acte 1 : Projets ────────────────────────────────────────────── @@ -71,7 +71,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.conceptProject.title', descKey: 'tour.conceptProject.desc', position: 'bottom', - group: 'Projets', + groupKey: 'tour.group.projects', }, { id: 'scan-btn', @@ -80,7 +80,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.scan.title', descKey: 'tour.scan.desc', position: 'bottom', - group: 'Projets', + groupKey: 'tour.group.projects', }, { id: 'click-new-project', @@ -91,7 +91,7 @@ export const TOUR_STEPS: TourStep[] = [ position: 'bottom', waitForClick: true, pulse: true, - group: 'Projets', + groupKey: 'tour.group.projects', }, { id: 'modal-overview', @@ -100,7 +100,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.modalOverview.title', descKey: 'tour.modalOverview.desc', position: 'left', - group: 'Projets', + groupKey: 'tour.group.projects', afterStep: closeModal, }, @@ -112,7 +112,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.navPlugins.title', descKey: 'tour.navPlugins.desc', position: 'bottom', - group: 'Plugins', + groupKey: 'tour.group.plugins', }, { id: 'add-plugin-btn', @@ -121,7 +121,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.addPlugin.title', descKey: 'tour.addPlugin.desc', position: 'bottom', - group: 'Plugins', + groupKey: 'tour.group.plugins', }, // ── Acte 3 : Discussions ────────────────────────────────────────── @@ -132,7 +132,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.navDiscussions.title', descKey: 'tour.navDiscussions.desc', position: 'bottom', - group: 'Discussions', + groupKey: 'tour.group.discussions', }, { id: 'disc-sidebar', @@ -141,7 +141,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.sidebar.title', descKey: 'tour.sidebar.desc', position: 'right', - group: 'Discussions', + groupKey: 'tour.group.discussions', }, { id: 'click-new-disc', @@ -152,7 +152,7 @@ export const TOUR_STEPS: TourStep[] = [ position: 'bottom', waitForClick: true, pulse: true, - group: 'Discussions', + groupKey: 'tour.group.discussions', }, { id: 'disc-form-overview', @@ -161,7 +161,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.discForm.title', descKey: 'tour.discForm.desc', position: 'left', - group: 'Discussions', + groupKey: 'tour.group.discussions', }, // ── Profiles — learn-by-doing, stays in the new-discussion form ── // Previous attempt drew a static tooltip that sat ON TOP of the form @@ -185,7 +185,7 @@ export const TOUR_STEPS: TourStep[] = [ position: 'left', waitForClick: true, pulse: true, - group: 'Discussions', + groupKey: 'tour.group.discussions', }, { id: 'disc-form-profile-chip', @@ -197,7 +197,7 @@ export const TOUR_STEPS: TourStep[] = [ position: 'left', waitForClick: true, pulse: true, - group: 'Discussions', + groupKey: 'tour.group.discussions', // Safety net: if the user navigated backward and closed the // accordion between step 12 and step 13, re-open it so the chip // is visible (and the spotlight can anchor on a real, visible @@ -213,7 +213,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.navAutomation.title', descKey: 'tour.navAutomation.desc', position: 'bottom', - group: 'Automatisation', + groupKey: 'tour.group.automation', }, // ── Acte 5 : Config ─────────────────────────────────────────────── @@ -224,7 +224,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.navConfig.title', descKey: 'tour.navConfig.desc', position: 'bottom', - group: 'Config', + groupKey: 'tour.group.config', }, { id: 'usage-section', @@ -233,7 +233,7 @@ export const TOUR_STEPS: TourStep[] = [ titleKey: 'tour.usage.title', descKey: 'tour.usage.desc', position: 'bottom', - group: 'Config', + groupKey: 'tour.group.config', }, // ── Fin ──────────────────────────────────────────────────────────── @@ -243,6 +243,6 @@ export const TOUR_STEPS: TourStep[] = [ selector: null, titleKey: 'tour.done.title', descKey: 'tour.done.desc', - group: 'Fin', + groupKey: 'tour.group.end', }, ]; diff --git a/frontend/src/components/workflows/RunDetail.tsx b/frontend/src/components/workflows/RunDetail.tsx index 8da9e557..c4e74538 100644 --- a/frontend/src/components/workflows/RunDetail.tsx +++ b/frontend/src/components/workflows/RunDetail.tsx @@ -18,6 +18,9 @@ const STATUS_COLORS: Record = { WaitingApproval: 'var(--kr-accent-ink)', // 0.7.0 — guard-stopped runs are amber (self-protection, not failure). StoppedByGuard: 'var(--kr-warning)', + // 0.8.11 — backend died mid-run (crash/restart). Neutral grey, not red: + // the workflow didn't fail, the host went away. + Interrupted: 'var(--kr-text-ghost)', }; export interface RunDetailProps { diff --git a/frontend/src/components/workflows/StepBranchMap.tsx b/frontend/src/components/workflows/StepBranchMap.tsx index 2b3a6648..72e0f1a0 100644 --- a/frontend/src/components/workflows/StepBranchMap.tsx +++ b/frontend/src/components/workflows/StepBranchMap.tsx @@ -49,17 +49,23 @@ export function StepBranchMap({ steps, t }: StepBranchMapProps) { ))} - {/* Goto arcs in the left gutter */} - {edges.map((e, k) => { - if (e.toIndex < 0) return null; // dangling target — skip drawing + {/* Goto arcs in the left gutter. Each drawable arc gets: a categorical + hue (fixed order, index 0..4 then a neutral — CVD-validated per theme + in CSS), a per-index geometric offset so overlapping arcs fan out + instead of coinciding, and a dash for backward loops (redundant with + colour → A11y). Hovering the map dims all arcs; hovering one restores + it — so a crossing line is easy to follow. */} + {edges.filter(e => e.toIndex >= 0).map((e, k) => { const depth = Math.min(Math.abs(e.toIndex - e.fromIndex), 3); - const bx = spineX - 6 - depth * 12; + const bx = spineX - 6 - depth * 12 - (k % 4) * 6; // fan out crossings const d = `M ${spineX} ${cy(e.fromIndex)} C ${bx} ${cy(e.fromIndex)}, ${bx} ${cy(e.toIndex)}, ${spineX} ${cy(e.toIndex)}`; + const slot = k < 5 ? String(k) : 'x'; // fixed-order slot 0..4, then neutral (never cycled) + const cls = `wf-bm-arc wf-bm-arc-c${slot}${e.backward ? ' wf-bm-arc-back' : ''}`; return ( @@ -69,8 +75,8 @@ export function StepBranchMap({ steps, t }: StepBranchMapProps) { })}
- — {t('wf.branchMap.legendForward')} - — {t('wf.branchMap.legendLoop')} + 🎨 {t('wf.branchMap.legendForward')} + ⇠ {t('wf.branchMap.legendLoop')}
); diff --git a/frontend/src/components/workflows/WorkflowDetail.tsx b/frontend/src/components/workflows/WorkflowDetail.tsx index ff8b08d3..239fd205 100644 --- a/frontend/src/components/workflows/WorkflowDetail.tsx +++ b/frontend/src/components/workflows/WorkflowDetail.tsx @@ -163,6 +163,9 @@ export interface WorkflowDetailProps { /** #11 — jump to a SPECIFIC child run (workflow + run id): opens that * workflow's detail and focuses the exact run. */ onNavigateToRun?: (workflowId: string, runId: string) => void; + /** 0.8.11 UX — one-click enable/disable from the detail header (a disabled + * workflow's launch button is inert; this is the visible way out). */ + onToggleEnabled?: (enabled: boolean) => void; /** #11 — a run id to auto-expand + scroll into view once loaded. */ focusRunId?: string | null; /** 0.7.0 UX pass — export the workflow as a JSON file. The handler @@ -650,6 +653,7 @@ function StepCard({ step, index, agentAccess, projectId, t, quickPromptsById, wo {AGENT_LABELS[step.agent] ?? step.agent} )} + {isAgentLike && } {isAgentLike && checkAgentRestricted(agentAccess ?? undefined, step.agent) && ( @@ -1120,6 +1124,27 @@ export function LiveFinishedBanner({ * `usesTokens` splits steps into "agent" (LLM, costs tokens) vs the * mechanical/deterministic ones (0 token) — the headline distinction Kronn * sells. Agent + BatchQuickPrompt both run an LLM. */ +/** 0.8.11 — per-step model tier for display. Surface only a non-default choice + * (economy/reasoning) — `default` is the norm, showing it would be noise. The + * emote mirrors the wizard selector so a step's model class is scannable. */ +const TIER_EMOTE: Record = { economy: '⚡', default: '🎯', reasoning: '🧠' }; +function stepTier(step: WorkflowStep): 'economy' | 'reasoning' | null { + const tr = step.agent_settings?.tier; + return tr === 'economy' || tr === 'reasoning' ? tr : null; +} + +/** Non-default model-tier badge for an Agent step. `chip` = compact (emote only) + * for the pipeline; otherwise emote + label for the detail card. Renders + * nothing for a default/unset tier. */ +function TierBadge({ step, t, chip }: { step: WorkflowStep; t: (k: string) => string; chip?: boolean }) { + const tr = stepTier(step); + if (!tr) return null; + const label = t(`disc.tier.${tr}`); + return chip + ? {TIER_EMOTE[tr]} + : {TIER_EMOTE[tr]} {label}; +} + function compactStepMeta(step: WorkflowStep): { kind: string; Icon: typeof Plug; usesTokens: boolean } { switch (step.step_type?.type) { case 'ApiCall': return { kind: 'api', Icon: Plug, usesTokens: false }; @@ -1133,7 +1158,7 @@ function compactStepMeta(step: WorkflowStep): { kind: string; Icon: typeof Plug; } } -export function WorkflowDetail({ workflow, runs, liveRun, onTrigger, onRefresh, onEdit, onDeleteRun, onDeleteAllRuns, triggering, agentAccess, onNavigateToBatch, onNavigateToWorkflow, onNavigateToRun, focusRunId, onExport, onGateDecided }: WorkflowDetailProps) { +export function WorkflowDetail({ workflow, runs, liveRun, onTrigger, onRefresh, onEdit, onDeleteRun, onDeleteAllRuns, triggering, agentAccess, onNavigateToBatch, onNavigateToWorkflow, onNavigateToRun, focusRunId, onExport, onGateDecided, onToggleEnabled }: WorkflowDetailProps) { const { t } = useT(); const [showRuns, setShowRuns] = useState(true); // Run-list control bar (#2): status filter + free-text search + fold past N. @@ -1353,10 +1378,29 @@ export function WorkflowDetail({ workflow, runs, liveRun, onTrigger, onRefresh, className="wf-small-btn wf-small-btn-accent" onClick={onTrigger} disabled={!workflow.enabled || triggering} + title={!workflow.enabled ? t('wf.launchDisabledHint') : undefined} > {triggering ? : } {t('wf.launch')} + {/* 0.8.11 UX — a disabled workflow used to leave "Lancer" silently + inert (clone lands disabled by design → user clicks, nothing + happens, no clue why). Make the state VISIBLE and actionable: + an explanatory chip + a one-click enable. */} + {!workflow.enabled && ( + + ⏸ {t('wf.disabledChip')} + + )} + {!workflow.enabled && onToggleEnabled && ( + + )}
{/* Trigger info */} @@ -1440,6 +1484,7 @@ export function WorkflowDetail({ workflow, runs, liveRun, onTrigger, onRefresh, {AGENT_LABELS[step.agent] ?? step.agent} )} + {isAgentStep && } ); diff --git a/frontend/src/components/workflows/WorkflowWizard.tsx b/frontend/src/components/workflows/WorkflowWizard.tsx index 5fa0bb47..65ac8a8e 100644 --- a/frontend/src/components/workflows/WorkflowWizard.tsx +++ b/frontend/src/components/workflows/WorkflowWizard.tsx @@ -14,7 +14,7 @@ import type { WorkspaceConfig, StepConditionRule, CreateWorkflowRequest, Skill, AgentProfile, Directive, WorkflowSuggestion, QuickPrompt, QuickApi, WorkflowGuards, - PromptVariable, WorkflowSummary, + PromptVariable, WorkflowSummary, ModelTier, } from '../../types/generated'; import { ExecutionLimitsCard } from './ExecutionLimitsCard'; import type { AgentsConfig } from '../../types/generated'; @@ -1495,16 +1495,37 @@ export function WorkflowWizard({ projects, editWorkflow, onDone, onCancel, insta placeholder={t('wiz.stepName')} /> {(!step.step_type || step.step_type.type === 'Agent') && ( - + <> + + {/* 0.8.11 — per-step model TIER. Lets one workflow mix + models (e.g. an economy 8b filter + a reasoning 32b + gate for a local 2-stage review). Maps to + agent_settings.tier → the agent's configured + economy/default/reasoning model. */} + + )} {/* 0.6.0 UX pass — reorder buttons. Marie + Antony : "je peux pas déplacer mes steps". On expose 2 chevrons diff --git a/frontend/src/components/workflows/__tests__/WorkflowDetail.actions.test.tsx b/frontend/src/components/workflows/__tests__/WorkflowDetail.actions.test.tsx index b8ab508b..a0410688 100644 --- a/frontend/src/components/workflows/__tests__/WorkflowDetail.actions.test.tsx +++ b/frontend/src/components/workflows/__tests__/WorkflowDetail.actions.test.tsx @@ -198,6 +198,28 @@ describe('WorkflowDetail — header actions', () => { expect(launch.disabled).toBe(true); }); + // ── 0.8.11 UX — a disabled workflow must EXPLAIN itself, not sit mute ── + it('disabled workflow: launch button carries an explanatory tooltip', () => { + renderDetail({ workflow: mkWorkflow({ enabled: false }) }); + const launch = screen.getByText('wf.launch').closest('button'); + expect(launch).toBeDisabled(); + expect(launch).toHaveAttribute('title', 'wf.launchDisabledHint'); + }); + + it('disabled workflow: shows the Désactivé chip and a one-click Activer that calls onToggleEnabled(true)', () => { + const onToggleEnabled = vi.fn(); + renderDetail({ workflow: mkWorkflow({ enabled: false }), onToggleEnabled }); + expect(screen.getByText(/wf\.disabledChip/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('wf.enableNow')); + expect(onToggleEnabled).toHaveBeenCalledWith(true); + }); + + it('enabled workflow: no chip, no Activer button', () => { + renderDetail({ workflow: mkWorkflow({ enabled: true }), onToggleEnabled: vi.fn() }); + expect(screen.queryByText(/wf\.disabledChip/)).toBeNull(); + expect(screen.queryByText('wf.enableNow')).toBeNull(); + }); + it('disables the launch button when the workflow is disabled', () => { renderDetail({ workflow: mkWorkflow({ enabled: false }) }); const launch = screen.getByText('wf.launch').closest('button') as HTMLButtonElement; @@ -320,6 +342,30 @@ describe('WorkflowDetail — per-run actions', () => { }); }); +// ---- 0.8.11 — per-step model tier badge ----------------------------------- +describe('WorkflowDetail — step model tier badge', () => { + it('shows a tier badge for a non-default (reasoning) Agent step', () => { + renderDetail({ + workflow: mkWorkflow({ + steps: [mkStep({ name: 'reason', step_type: { type: 'Agent' }, + agent_settings: { tier: 'reasoning' } })], + }), + }); + expect(screen.getAllByTitle('disc.tier.reasoning').length).toBeGreaterThan(0); + }); + + it('shows no tier badge for a default-tier step (default = the norm, no noise)', () => { + renderDetail({ + workflow: mkWorkflow({ + steps: [mkStep({ name: 'reason', step_type: { type: 'Agent' }, + agent_settings: { tier: 'default' } })], + }), + }); + expect(screen.queryByTitle('disc.tier.default')).toBeNull(); + expect(screen.queryByTitle('disc.tier.reasoning')).toBeNull(); + }); +}); + // ---- synthesized live run (effectiveLiveRun from runs[]) -------------- describe('WorkflowDetail — synthesized live run', () => { diff --git a/frontend/src/lib/__tests__/batchTriage.test.ts b/frontend/src/lib/__tests__/batchTriage.test.ts index 15c4b905..92315f82 100644 --- a/frontend/src/lib/__tests__/batchTriage.test.ts +++ b/frontend/src/lib/__tests__/batchTriage.test.ts @@ -17,7 +17,7 @@ const baseDiscussion: Discussion = { message_count: 0, non_system_message_count: 0, archived: false, - pinned: false, + pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-06-01T10:00:00Z', updated_at: '2026-06-01T10:00:00Z', diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts index 9e7fd8fa..045a0fd8 100644 --- a/frontend/src/lib/__tests__/i18n.test.ts +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { t, getUILocale, setUILocale, UI_LOCALES, type UILocale } from '../i18n'; +import { t, getUILocale, setUILocale, detectBrowserLocale, UI_LOCALES, type UILocale } from '../i18n'; describe('i18n', () => { describe('t() — translation function', () => { @@ -47,9 +47,12 @@ describe('i18n', () => { }); describe('locale persistence', () => { - it('defaults to fr when nothing stored', () => { + it('follows the browser language when nothing stored (D10)', () => { localStorage.clear(); - expect(getUILocale()).toBe('fr'); + // jsdom's navigator.language is 'en-US' → detection returns 'en' + // (no more hardcoded French default). A supported browser lang wins. + expect(getUILocale()).toBe(detectBrowserLocale()); + expect(['fr', 'en', 'es']).toContain(getUILocale()); }); it('persists and retrieves locale', () => { @@ -61,9 +64,9 @@ describe('i18n', () => { localStorage.clear(); }); - it('ignores invalid stored value', () => { + it('ignores invalid stored value (falls back to browser detection)', () => { localStorage.setItem('kronn:ui-locale', 'invalid'); - expect(getUILocale()).toBe('fr'); + expect(getUILocale()).toBe(detectBrowserLocale()); localStorage.clear(); }); }); diff --git a/frontend/src/lib/__tests__/regression.test.ts b/frontend/src/lib/__tests__/regression.test.ts index ba274a36..aa5f44e6 100644 --- a/frontend/src/lib/__tests__/regression.test.ts +++ b/frontend/src/lib/__tests__/regression.test.ts @@ -64,7 +64,7 @@ describe('regression tests', () => { participants: ['ClaudeCode'], messages: [], message_count: 5, non_system_message_count: 5, - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', @@ -90,7 +90,7 @@ describe('regression tests', () => { messages: [], // empty from list endpoint message_count: 10, // inflated by 8 System rows non_system_message_count: 2, // the real "to read" count - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 44bfed79..979cf48c 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -17,8 +17,28 @@ export function getUILocale(): UILocale { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored && ['fr', 'en', 'es'].includes(stored)) return stored as UILocale; - } catch { /* no localStorage available — fall back to default */ } - return 'fr'; + } catch { /* no localStorage available — fall back to browser detection */ } + // 0.8.11 (D10) — no explicit choice: follow the browser's preferred language + // instead of hardcoding French. Falls back to English (the ~95% of the market + // that isn't francophone shouldn't hit a French UI on first load). + return detectBrowserLocale(); +} + +/** Map the browser's language preferences to a supported UI locale, else 'en'. */ +export function detectBrowserLocale(): UILocale { + try { + const langs = + typeof navigator !== 'undefined' + ? (navigator.languages && navigator.languages.length + ? navigator.languages + : [navigator.language]).filter(Boolean) + : []; + for (const l of langs) { + const base = String(l).toLowerCase().split('-')[0]; + if (base === 'fr' || base === 'en' || base === 'es') return base; + } + } catch { /* navigator unavailable */ } + return 'en'; } export function setUILocale(locale: UILocale) { @@ -785,6 +805,13 @@ const fr: TranslationDict = { 'tour.next': 'Suivant', 'tour.prev': 'Précédent', 'tour.skip': 'Passer le tour', + 'tour.group.welcome': 'Bienvenue', + 'tour.group.projects': 'Projets', + 'tour.group.plugins': 'Plugins', + 'tour.group.discussions': 'Discussions', + 'tour.group.automation': 'Automatisation', + 'tour.group.config': 'Config', + 'tour.group.end': 'Fin', 'tour.finish': 'Terminer', 'tour.clickHint': 'Cliquez sur l\'élément en surbrillance pour continuer', 'tour.replayHint': 'Relancer le tour guidé', @@ -1528,6 +1555,11 @@ const fr: TranslationDict = { 'wf.back': 'Retour', 'wf.refresh': 'Rafraîchir', 'wf.launch': 'Lancer', + 'wf.launchDisabledHint': 'Workflow désactivé — il ne peut pas être lancé. Clique sur « Activer » (les clones arrivent désactivés par sécurité).', + 'wf.disabledChip': 'Désactivé', + 'wf.enableNow': 'Activer', + 'wf.enabledToast': 'Workflow activé — tu peux le lancer.', + 'wf.disabledToast': 'Workflow désactivé.', 'wf.running': 'Exécution en cours', 'wf.runDone': 'Run terminé — {0}', 'wf.runWaiting': 'En attente de ta validation', @@ -1784,8 +1816,8 @@ suggestion KRONN:APPLY ; cela écraserait la vraie valeur par un placeholder 'wf.runs.groupFallback': 'Exécutions liées', 'wf.branchMap.title': 'Carte de branchement', 'wf.branchMap.onTrigger': 'si « {0} »', - 'wf.branchMap.legendForward': 'saut', - 'wf.branchMap.legendLoop': 'boucle (retour arrière)', + 'wf.branchMap.legendForward': 'une couleur par saut', + 'wf.branchMap.legendLoop': 'pointillé = boucle (retour arrière)', 'wf.pausedJustNow': 'à l\'instant', 'wf.pausedMinutes': 'en pause depuis {0} min', 'wf.pausedHours': 'en pause depuis {0}h', @@ -2208,7 +2240,7 @@ Termine par [SIGNAL: OK].`, 'wiz.preset.ticketToPr.analyzePrompt': 'Analyse le ticket suivant et produis un plan structuré :\n---\n{{steps.fetch_issue.data}}\n---\n\n# Tu vas charger automatiquement les skills `writing-plans` et `brainstorming` :\n- Explore l\'intent du ticket avant l\'implémentation (3-5 questions critiques)\n- Liste les hypothèses + les points d\'incertitude\n- Découpe en sous-tâches (1-3 jours chacune max)\n- Identifie les risques et la stratégie de test\n\n# Output Structured :\n```json\n{\n "data": {\n "summary": "",\n "complexity": "low | medium | high",\n "questions": ["..."],\n "subtasks": [{"id": 1, "title": "...", "description": "..."}],\n "test_strategy": "..."\n },\n "status": "OK",\n "summary": "Plan en N sous-tâches, complexité "\n}\n```\n\n# IMPORTANT — écris aussi le plan final lisible dans `.kronn/plan.md` (racine du repo) : intention figée que le sous-workflow d\'implémentation lira. Pas de secrets dedans.', '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é. Lis l\'intention figée dans `.kronn/plan.md` (racine du repo). Journalise CHAQUE écart (mock, sous-tâche reportée, choix non prévu) dans `.kronn/decisions.md` (append, crée-le au besoin) avec le pourquoi.\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 + écarts : lis `.kronn/plan.md` et `.kronn/decisions.md`\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# Inspecte le DIFF réel (obligatoire — ne te fie pas au récap) :\n- `git status`, `git diff --stat`, `git diff` : lis les changements réels.\n- Aucun fichier hors-scope touché (le diff ne couvre QUE ce que le plan prévoit).\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 (OBLIGATOIRE — jamais par omission) :\nDans l\'envelope, `data.verdict` = \"APPROVED\" ou \"NEEDS_CHANGES\", `data.blocking_findings` = liste (vide si APPROVED). Tests skippés (`[SIGNAL: SKIPPED]`), diff illisible ou moindre doute → **NEEDS_CHANGES**. Ne conclus JAMAIS APPROVED par défaut.\nSi NEEDS_CHANGES, émets aussi `---STATE:last_review=---`.\nPuis, en TOUTE DERNIÈRE ligne (après l\'envelope), exactement un : `[SIGNAL: APPROVED]` ou `[SIGNAL: NEEDS_CHANGES]`.', + 'wiz.preset.ticketToPr.reviewPrompt': 'Review l\'implémentation produite par `implement` et le résultat des tests `run_tests`.\n\nContexte :\n- Plan original + écarts : lis `.kronn/plan.md` et `.kronn/decisions.md`\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# Inspecte le DIFF réel (obligatoire — ne te fie pas au récap) :\n- `git status`, `git diff --stat`, `git diff` : lis les changements réels.\n- Aucun fichier hors-scope touché (le diff ne couvre QUE ce que le plan prévoit).\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 (OBLIGATOIRE — jamais par omission) :\nDans l\'envelope, `data.verdict` = "APPROVED" ou "NEEDS_CHANGES", `data.blocking_findings` = liste (vide si APPROVED). Tests skippés (`[SIGNAL: SKIPPED]`), diff illisible ou moindre doute → **NEEDS_CHANGES**. Ne conclus JAMAIS APPROVED par défaut.\nSi NEEDS_CHANGES, émets aussi `---STATE:last_review=---`.\nPuis, en TOUTE DERNIÈRE ligne (après l\'envelope), exactement un : `[SIGNAL: APPROVED]` ou `[SIGNAL: NEEDS_CHANGES]`.', 'wiz.preset.ticketToPr.createPrPrompt': 'L\'humain a approuvé la création de la PR.\n\n⚠️ GARDE-FOU AVANT TOUT PUSH : relance TOI-MÊME la suite de tests dans le worktree et lis le résultat réel (le sous-workflow `implement_verify` a fini avec le statut {{steps.implement_verify.status}}). Si les tests ÉCHOUENT ou ont été SKIPPÉS (`[SIGNAL: SKIPPED]`), NE crée PAS la PR — termine par `[SIGNAL: ERROR]` en expliquant pourquoi. Ne pousse QUE sur des tests réellement verts.\n\nContexte :\n- Ticket : {{steps.fetch_issue.data}}\n- Implémentation & review : {{steps.implement_verify.summary}}\n- Écarts journalisés : lis `.kronn/decisions.md`\n- Verdict review : {{steps.implement_verify.data.last_output}}\n\n# GARDES-FOUS supplémentaires (si l\'un échoue → `[SIGNAL: ERROR]`, ne pousse rien) :\n- `gh auth status` OK (sinon auth GitHub manquante → STOP).\n- HEAD n\'est PAS la branche par défaut (main/master) : tu dois être sur une branche de feature, sinon STOP.\n- Pas de PR déjà ouverte (`gh pr list --head `) : si une existe, ne recrée pas → réutilise/STOP.\n- Si tu ne peux pas produire `pr_url` à la fin → `[SIGNAL: ERROR]` (la PR n\'a pas abouti).\n\n# Skills auto-chargées : `finishing-a-development-branch` (qui présente d\'habitude plusieurs options et attend un choix) + `verification-before-completion`. ⚠️ DANS CE WORKFLOW l\'option est DÉJÀ tranchée = **push + PR** : n\'attends aucune décision humaine, ne propose pas les autres options, exécute directement.\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êt à pousser la branche + créer la PR ?\n\n**Plan :** {{steps.analyze.summary}}\n**Implémentation & vérif :** {{steps.implement_verify.summary}}\n**Verdict review :**\n{{steps.implement_verify.data.last_output}}\n\n⚠️ Vérifie le résultat de l\'étape `run_tests` ci-dessus. Si tu vois `[SIGNAL: SKIPPED]` (tests non lancés) ou un exit code non nul, décide en connaissance de cause — rien n\'a encore été poussé.\n\nApprouve pour pousser la branche et créer la PR, ou demande des changements pour relancer le sous-workflow `implement_verify` avec ton feedback.', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot terminé : {{state.pr_url}} — {{steps.implement_verify.summary}}', @@ -2220,7 +2252,7 @@ Termine par [SIGNAL: OK].`, '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}}` (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 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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `\"unchanged\": [\"\", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', + '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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `"unchanged": ["", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', 'wiz.preset.feasibilityAutopilot.childName': 'Implement & verify (feasibility loop)', 'wiz.preset.feasibilityAutopilot.implStepDesc': 'implement → static_checks → scope/completeness checks → commit, run as a sub-workflow sharing the parent worktree (Phase 2). Reads the approved manifest from `.kronn/triage-manifest.md`, logs deviations to `.kronn/decisions.md`, inserts KRONN-* markers. Full test suites + drift audit run ONCE at the parent afterwards. On child failure → re-triage (max 3).', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Revue humaine du manifest triage avant le code. `gate_request_changes_target: triage` permet de boucler max 5 fois.', @@ -2332,6 +2364,7 @@ Termine par [SIGNAL: OK].`, 'wiz.clickToInsert': 'Cliquer pour insérer', 'wiz.example': 'Exemple : workflow multi-step', 'wiz.stepName': 'Nom du step', + 'wiz.tierHint': 'Palier de modèle de ce step (⚡ éco / 🎯 standard / 🧠 raisonnement) — permet de mixer plusieurs modèles dans un même workflow.', 'wiz.advanced': 'Avancé', 'wiz.mode': 'Mode', 'wiz.debateAgents': 'Agents du débat', @@ -2581,6 +2614,7 @@ Termine par [SIGNAL: OK].`, 'common.cancel': 'Annuler', 'common.dismiss': 'Fermer', 'common.save': 'Sauvegarder', + 'common.saving': 'Enregistrement…', 'common.delete': 'Supprimer', 'common.close': 'Fermer', 'common.add': 'Ajouter', @@ -3382,6 +3416,13 @@ const en: TranslationDict = { 'tour.next': 'Next', 'tour.prev': 'Previous', 'tour.skip': 'Skip tour', + 'tour.group.welcome': 'Welcome', + 'tour.group.projects': 'Projects', + 'tour.group.plugins': 'Plugins', + 'tour.group.discussions': 'Discussions', + 'tour.group.automation': 'Automation', + 'tour.group.config': 'Config', + 'tour.group.end': 'Done', 'tour.finish': 'Finish', 'tour.clickHint': 'Click the highlighted element to continue', 'tour.replayHint': 'Replay guided tour', @@ -4122,6 +4163,11 @@ const en: TranslationDict = { 'wf.back': 'Back', 'wf.refresh': 'Refresh', 'wf.launch': 'Trigger', + 'wf.launchDisabledHint': 'Workflow disabled — it cannot be triggered. Click "Enable" (clones land disabled by design).', + 'wf.disabledChip': 'Disabled', + 'wf.enableNow': 'Enable', + 'wf.enabledToast': 'Workflow enabled — you can trigger it now.', + 'wf.disabledToast': 'Workflow disabled.', 'wf.running': 'Running', 'wf.runDone': 'Run finished — {0}', 'wf.runWaiting': 'Awaiting your decision', @@ -4378,8 +4424,8 @@ suggestion; that would overwrite the real value with a placeholder 'wf.runs.groupFallback': 'Related runs', 'wf.branchMap.title': 'Branch map', 'wf.branchMap.onTrigger': 'if "{0}"', - 'wf.branchMap.legendForward': 'jump', - 'wf.branchMap.legendLoop': 'loop (backward)', + 'wf.branchMap.legendForward': 'one colour per jump', + 'wf.branchMap.legendLoop': 'dashed = loop (backward)', 'wf.pausedJustNow': 'just now', 'wf.pausedMinutes': 'paused for {0} min', 'wf.pausedHours': 'paused for {0}h', @@ -4802,7 +4848,7 @@ End with [SIGNAL: OK].`, 'wiz.preset.ticketToPr.analyzePrompt': 'Analyze the following ticket and produce a structured plan:\n---\n{{steps.fetch_issue.data}}\n---\n\n# You will auto-load the `writing-plans` and `brainstorming` skills:\n- Explore the ticket\'s intent before implementation (3-5 critical questions)\n- List assumptions + uncertainty points\n- Break into subtasks (1-3 days each max)\n- Identify risks and test strategy\n\n# Structured output:\n```json\n{\n "data": {\n "summary": "",\n "complexity": "low | medium | high",\n "questions": ["..."],\n "subtasks": [{"id": 1, "title": "...", "description": "..."}],\n "test_strategy": "..."\n },\n "status": "OK",\n "summary": "Plan in N subtasks, complexity "\n}\n```\n\n# IMPORTANT — also write the final readable plan to `.kronn/plan.md` (repo root): the frozen intent the implementation sub-workflow will read. No secrets in it.', '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. Read the frozen intent in `.kronn/plan.md` (repo root). Log EVERY deviation (mock, deferred subtask, unplanned choice) to `.kronn/decisions.md` (append, create if missing) with the reason.\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 + deviations: read `.kronn/plan.md` and `.kronn/decisions.md`\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# Inspect the real DIFF (mandatory — do not trust the recap):\n- `git status`, `git diff --stat`, `git diff`: read the actual changes.\n- No out-of-scope file touched (the diff covers ONLY what the plan calls for).\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 (MANDATORY — never by omission):\nIn the envelope, `data.verdict` = \"APPROVED\" or \"NEEDS_CHANGES\", `data.blocking_findings` = list (empty if APPROVED). Skipped tests (`[SIGNAL: SKIPPED]`), unreadable diff, or any doubt → **NEEDS_CHANGES**. NEVER conclude APPROVED by default.\nIf NEEDS_CHANGES, also emit `---STATE:last_review=---`.\nThen, on the VERY LAST line (after the envelope), exactly one: `[SIGNAL: APPROVED]` or `[SIGNAL: NEEDS_CHANGES]`.', + 'wiz.preset.ticketToPr.reviewPrompt': 'Review the implementation produced by `implement` and the result of the `run_tests` step.\n\nContext:\n- Original plan + deviations: read `.kronn/plan.md` and `.kronn/decisions.md`\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# Inspect the real DIFF (mandatory — do not trust the recap):\n- `git status`, `git diff --stat`, `git diff`: read the actual changes.\n- No out-of-scope file touched (the diff covers ONLY what the plan calls for).\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 (MANDATORY — never by omission):\nIn the envelope, `data.verdict` = "APPROVED" or "NEEDS_CHANGES", `data.blocking_findings` = list (empty if APPROVED). Skipped tests (`[SIGNAL: SKIPPED]`), unreadable diff, or any doubt → **NEEDS_CHANGES**. NEVER conclude APPROVED by default.\nIf NEEDS_CHANGES, also emit `---STATE:last_review=---`.\nThen, on the VERY LAST line (after the envelope), exactly one: `[SIGNAL: APPROVED]` or `[SIGNAL: NEEDS_CHANGES]`.', 'wiz.preset.ticketToPr.createPrPrompt': 'The human approved creating the PR.\n\n⚠️ GUARD BEFORE ANY PUSH: re-run the test suite YOURSELF in the worktree and read the real result (the `implement_verify` sub-workflow finished with status {{steps.implement_verify.status}}). If tests FAIL or were SKIPPED (`[SIGNAL: SKIPPED]`), do NOT create the PR — end with `[SIGNAL: ERROR]` explaining why. Only push on genuinely green tests.\n\nContext:\n- Ticket: {{steps.fetch_issue.data}}\n- Implementation & review: {{steps.implement_verify.summary}}\n- Logged deviations: read `.kronn/decisions.md`\n- Review verdict: {{steps.implement_verify.data.last_output}}\n\n# Extra GUARDS (if any fails → `[SIGNAL: ERROR]`, push nothing):\n- `gh auth status` OK (else GitHub auth missing → STOP).\n- HEAD is NOT the default branch (main/master): you must be on a feature branch, else STOP.\n- No PR already open (`gh pr list --head `): if one exists, do not recreate → reuse/STOP.\n- If you cannot emit `pr_url` at the end → `[SIGNAL: ERROR]` (the PR did not go through).\n\n# Auto-loaded skills: `finishing-a-development-branch` (which normally presents several options and waits for a choice) + `verification-before-completion`. ⚠️ IN THIS WORKFLOW the option is ALREADY decided = **push + PR**: do not wait for a human decision, do not offer the other options, execute directly.\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': '## Ready to push the branch + open the PR?\n\n**Plan:** {{steps.analyze.summary}}\n**Implementation & verify:** {{steps.implement_verify.summary}}\n**Review verdict:**\n{{steps.implement_verify.data.last_output}}\n\n⚠️ Check the `run_tests` step result above. If you see `[SIGNAL: SKIPPED]` (tests not run) or a non-zero exit code, decide accordingly — nothing has been pushed yet.\n\nApprove to push the branch and open the PR, or request changes to re-run the `implement_verify` sub-workflow with your feedback.', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot complete: {{state.pr_url}} — {{steps.implement_verify.summary}}', @@ -4814,7 +4860,7 @@ End with [SIGNAL: OK].`, '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}}` (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 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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `\"unchanged\": [\"\", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', + '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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `"unchanged": ["", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', 'wiz.preset.feasibilityAutopilot.childName': 'Implement & verify (feasibility loop)', 'wiz.preset.feasibilityAutopilot.implStepDesc': 'implement → static_checks → scope/completeness checks → commit, run as a sub-workflow sharing the parent worktree (Phase 2). Reads the approved manifest from `.kronn/triage-manifest.md`, logs deviations to `.kronn/decisions.md`, inserts KRONN-* markers. Full test suites + drift audit run ONCE at the parent afterwards. On child failure → re-triage (max 3).', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Human review of the triage manifest before code. `gate_request_changes_target: triage` allows up to 5 loops.', @@ -4926,6 +4972,7 @@ End with [SIGNAL: OK].`, 'wiz.clickToInsert': 'Click to insert', 'wiz.example': 'Example: multi-step workflow', 'wiz.stepName': 'Step name', + 'wiz.tierHint': 'Model tier for this step (⚡ economy / 🎯 standard / 🧠 reasoning) — lets one workflow mix several models.', 'wiz.advanced': 'Advanced', 'wiz.mode': 'Mode', 'wiz.debateAgents': 'Debate agents', @@ -5175,6 +5222,7 @@ End with [SIGNAL: OK].`, 'common.cancel': 'Cancel', 'common.dismiss': 'Dismiss', 'common.save': 'Save', + 'common.saving': 'Saving…', 'common.delete': 'Delete', 'common.close': 'Close', 'common.add': 'Add', @@ -5976,6 +6024,13 @@ const es: TranslationDict = { 'tour.next': 'Siguiente', 'tour.prev': 'Anterior', 'tour.skip': 'Saltar tour', + 'tour.group.welcome': 'Bienvenida', + 'tour.group.projects': 'Proyectos', + 'tour.group.plugins': 'Plugins', + 'tour.group.discussions': 'Discusiones', + 'tour.group.automation': 'Automatización', + 'tour.group.config': 'Config', + 'tour.group.end': 'Fin', 'tour.finish': 'Terminar', 'tour.clickHint': 'Haga clic en el elemento resaltado para continuar', 'tour.replayHint': 'Repetir tour guiado', @@ -6716,6 +6771,11 @@ const es: TranslationDict = { 'wf.edit': 'Editar', 'wf.refresh': 'Actualizar', 'wf.launch': 'Ejecutar', + 'wf.launchDisabledHint': 'Workflow desactivado — no se puede ejecutar. Haz clic en «Activar» (los clones llegan desactivados por seguridad).', + 'wf.disabledChip': 'Desactivado', + 'wf.enableNow': 'Activar', + 'wf.enabledToast': 'Workflow activado — ya puedes ejecutarlo.', + 'wf.disabledToast': 'Workflow desactivado.', 'wf.running': 'En ejecución', 'wf.runDone': 'Run terminado — {0}', 'wf.runWaiting': 'Esperando tu decisión', @@ -6972,8 +7032,8 @@ KRONN:APPLY; eso sobreescribiría el valor real con un placeholder 'wf.runs.groupFallback': 'Ejecuciones relacionadas', 'wf.branchMap.title': 'Mapa de ramificación', 'wf.branchMap.onTrigger': 'si «{0}»', - 'wf.branchMap.legendForward': 'salto', - 'wf.branchMap.legendLoop': 'bucle (hacia atrás)', + 'wf.branchMap.legendForward': 'un color por salto', + 'wf.branchMap.legendLoop': 'punteado = bucle (hacia atrás)', 'wf.pausedJustNow': 'ahora mismo', 'wf.pausedMinutes': 'en pausa desde hace {0} min', 'wf.pausedHours': 'en pausa desde hace {0}h', @@ -7396,7 +7456,7 @@ Termina con [SIGNAL: OK].`, 'wiz.preset.ticketToPr.analyzePrompt': 'Analiza el siguiente ticket y produce un plan estructurado:\n---\n{{steps.fetch_issue.data}}\n---\n\n# Vas a cargar automáticamente las skills `writing-plans` y `brainstorming`:\n- Explora la intención del ticket antes de implementar (3-5 preguntas críticas)\n- Lista hipótesis + puntos de incertidumbre\n- Divide en sub-tareas (1-3 días cada una máx.)\n- Identifica riesgos y estrategia de tests\n\n# Output Structured:\n```json\n{\n "data": {\n "summary": "",\n "complexity": "low | medium | high",\n "questions": ["..."],\n "subtasks": [{"id": 1, "title": "...", "description": "..."}],\n "test_strategy": "..."\n },\n "status": "OK",\n "summary": "Plan en N sub-tareas, complejidad "\n}\n```\n\n# IMPORTANTE — escribe también el plan final legible en `.kronn/plan.md` (raíz del repo): la intención fijada que leerá el sub-workflow de implementación. Sin secretos.', '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. Lee la intención fijada en `.kronn/plan.md` (raíz del repo). Registra CADA desviación (mock, sub-tarea aplazada, decisión no prevista) en `.kronn/decisions.md` (append, créalo si falta) con el porqué.\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 + desviaciones: lee `.kronn/plan.md` y `.kronn/decisions.md`\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# Inspecciona el DIFF real (obligatorio — no te fíes del resumen):\n- `git status`, `git diff --stat`, `git diff`: lee los cambios reales.\n- Ningún archivo fuera de alcance tocado (el diff cubre SOLO lo que el plan prevé).\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 (OBLIGATORIO — nunca por omisión):\nEn el envelope, `data.verdict` = \"APPROVED\" o \"NEEDS_CHANGES\", `data.blocking_findings` = lista (vacía si APPROVED). Tests skippeados (`[SIGNAL: SKIPPED]`), diff ilegible o cualquier duda → **NEEDS_CHANGES**. NUNCA concluyas APPROVED por defecto.\nSi NEEDS_CHANGES, emite también `---STATE:last_review=---`.\nLuego, en la ÚLTIMA línea (tras el envelope), exactamente uno: `[SIGNAL: APPROVED]` o `[SIGNAL: NEEDS_CHANGES]`.', + 'wiz.preset.ticketToPr.reviewPrompt': 'Revisa la implementación producida por `implement` y el resultado del paso `run_tests`.\n\nContexto:\n- Plan original + desviaciones: lee `.kronn/plan.md` y `.kronn/decisions.md`\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# Inspecciona el DIFF real (obligatorio — no te fíes del resumen):\n- `git status`, `git diff --stat`, `git diff`: lee los cambios reales.\n- Ningún archivo fuera de alcance tocado (el diff cubre SOLO lo que el plan prevé).\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 (OBLIGATORIO — nunca por omisión):\nEn el envelope, `data.verdict` = "APPROVED" o "NEEDS_CHANGES", `data.blocking_findings` = lista (vacía si APPROVED). Tests skippeados (`[SIGNAL: SKIPPED]`), diff ilegible o cualquier duda → **NEEDS_CHANGES**. NUNCA concluyas APPROVED por defecto.\nSi NEEDS_CHANGES, emite también `---STATE:last_review=---`.\nLuego, en la ÚLTIMA línea (tras el envelope), exactamente uno: `[SIGNAL: APPROVED]` o `[SIGNAL: NEEDS_CHANGES]`.', 'wiz.preset.ticketToPr.createPrPrompt': 'El humano aprobó crear el PR.\n\n⚠️ GUARDA ANTES DE CUALQUIER PUSH: reejecuta TÚ MISMO la suite de tests en el worktree y lee el resultado real (el sub-workflow `implement_verify` terminó con estado {{steps.implement_verify.status}}). Si los tests FALLAN o fueron SKIPPED (`[SIGNAL: SKIPPED]`), NO crees el PR — termina con `[SIGNAL: ERROR]` explicando por qué. Solo push con tests verdaderamente verdes.\n\nContexto:\n- Ticket: {{steps.fetch_issue.data}}\n- Implementación & review: {{steps.implement_verify.summary}}\n- Desviaciones registradas: lee `.kronn/decisions.md`\n- Veredicto review: {{steps.implement_verify.data.last_output}}\n\n# GUARDAS adicionales (si alguna falla → `[SIGNAL: ERROR]`, no empujes nada):\n- `gh auth status` OK (si no, falta auth GitHub → STOP).\n- HEAD NO es la rama por defecto (main/master): debes estar en una rama de feature, si no STOP.\n- No hay PR ya abierto (`gh pr list --head `): si existe, no recrees → reutiliza/STOP.\n- Si no puedes emitir `pr_url` al final → `[SIGNAL: ERROR]` (el PR no se creó).\n\n# Skills auto-cargadas: `finishing-a-development-branch` (que normalmente presenta varias opciones y espera una decisión) + `verification-before-completion`. ⚠️ EN ESTE WORKFLOW la opción YA está decidida = **push + PR**: no esperes decisión humana, no ofrezcas las otras opciones, ejecuta directamente.\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': '## ¿Listo para subir la rama + abrir el PR?\n\n**Plan:** {{steps.analyze.summary}}\n**Implementación & verif:** {{steps.implement_verify.summary}}\n**Veredicto review:**\n{{steps.implement_verify.data.last_output}}\n\n⚠️ Revisa el resultado del paso `run_tests` arriba. Si ves `[SIGNAL: SKIPPED]` (tests no ejecutados) o un exit code distinto de cero, decide en consecuencia — todavía no se ha subido nada.\n\nAprueba para subir la rama y crear el PR, o solicita cambios para re-ejecutar el sub-workflow `implement_verify` con tu feedback.', 'wiz.preset.ticketToPr.notifyDoneBody': '✅ Ticket Autopilot completado: {{state.pr_url}} — {{steps.implement_verify.summary}}', @@ -7408,7 +7468,7 @@ Termina con [SIGNAL: OK].`, '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}}` (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 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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `\"unchanged\": [\"\", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', + '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. For EACH item also include `scope` (array of repo-relative paths/globs it touches), `complexity` (low|med|high), `mechanical` (true if fully determined by lifted values — generatable without further reasoning — else false), `acceptance` (a deterministic done-check), `depends_on` (array of item ids required first; empty when independent), and — ONLY when mechanical:true AND the change creates/fully rewrites small files (config/enum/tokens ≤ ~150 lines) — `files`: array of {path, content} with the COMPLETE final content (KRONN marker inside for decided/mocked); the engine applies them with ZERO agent run; complex edits → mechanical:false. If `.kronn/plan-review.md` exists, it contains the plan reviewer\'s findings from a previous round — you MUST address EVERY point in it; in that case emit an INCREMENTAL manifest: re-emit IN FULL only the items you change or add, list EVERY untouched item\'s id in a top-level `"unchanged": ["", ...]` array (the engine restores them verbatim from the previous round — do NOT re-emit their fields or `files[]` content), and still rewrite the complete `.kronn/triage-manifest.md`. GRANULARITY: one item = ONE cohesive concern (typically 8-20 items for a big ticket; never split one file edit across items, never bundle unrelated files). Besides the JSON envelope, write ONE file: `.kronn/triage-manifest.md` (full human-readable manifest). The machine work files (tasks.json, decision_ids.txt, files_touched.txt) are DERIVED automatically by the engine from your validated envelope — do NOT write them yourself.', 'wiz.preset.feasibilityAutopilot.childName': 'Implement & verify (feasibility loop)', 'wiz.preset.feasibilityAutopilot.implStepDesc': 'implement → static_checks → scope/completeness checks → commit, run as a sub-workflow sharing the parent worktree (Phase 2). Reads the approved manifest from `.kronn/triage-manifest.md`, logs deviations to `.kronn/decisions.md`, inserts KRONN-* markers. Full test suites + drift audit run ONCE at the parent afterwards. On child failure → re-triage (max 3).', 'wiz.preset.feasibilityAutopilot.gateDesc': 'Revisión humana del manifest de triage antes del código. `gate_request_changes_target: triage` permite hasta 5 bucles.', @@ -7520,6 +7580,7 @@ Termina con [SIGNAL: OK].`, 'wiz.clickToInsert': 'Clic para insertar', 'wiz.example': 'Ejemplo: workflow multi-step', 'wiz.stepName': 'Nombre del step', + 'wiz.tierHint': 'Nivel de modelo de este step (⚡ eco / 🎯 estándar / 🧠 razonamiento) — permite mezclar varios modelos en un workflow.', 'wiz.advanced': 'Avanzado', 'wiz.mode': 'Modo', 'wiz.debateAgents': 'Agentes del debate', @@ -7769,6 +7830,7 @@ Termina con [SIGNAL: OK].`, 'common.cancel': 'Cancelar', 'common.dismiss': 'Cerrar', 'common.save': 'Guardar', + 'common.saving': 'Guardando…', 'common.delete': 'Eliminar', 'common.close': 'Cerrar', 'common.add': 'Añadir', diff --git a/frontend/src/lib/kronnToolParser.ts b/frontend/src/lib/kronnToolParser.ts index eae624b1..5631841c 100644 --- a/frontend/src/lib/kronnToolParser.ts +++ b/frontend/src/lib/kronnToolParser.ts @@ -49,7 +49,7 @@ export interface KronnToolCall { // namespace prefixes (`mcp__github__create_issue`). Permissive // regex accepts any non-paren, non-bracket sequence. const KRONN_INTERNAL_RE = /^\[kronn-internal: ([a-z_]+)(?:\(([\s\S]*?)\))?(?: → ([\s\S]*))?\]$/; -const AGENT_NATIVE_RE = /^\[agent-native: ([^()\[\]]+?)(?:\(([\s\S]*?)\))?(?: → ([\s\S]*))?\]$/; +const AGENT_NATIVE_RE = /^\[agent-native: ([^()[\]]+?)(?:\(([\s\S]*?)\))?(?: → ([\s\S]*))?\]$/; /** Parse a System message's content. Returns `null` when the content * isn't a tool trace — callers fall back to the default System-message diff --git a/frontend/src/pages/McpPage.tsx b/frontend/src/pages/McpPage.tsx index 4f2fca71..b0395f72 100644 --- a/frontend/src/pages/McpPage.tsx +++ b/frontend/src/pages/McpPage.tsx @@ -273,7 +273,9 @@ export function McpPage({ projects, mcpOverview, mcpRegistry, refetchMcps, initi }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - // eslint-disable-next-line react-hooks/exhaustive-deps + // (0.8.11 — the old `eslint-disable react-hooks/exhaustive-deps` here was + // dead: with the React-19 ruleset the rule no longer reports on this hook, + // so the directive itself warned as unused. Removed rather than restored.) }, [selectedConfigId, editingCustomServerId]); // "Show more" for project toggles per config const [expandedProjectLists, setExpandedProjectLists] = useState>(new Set()); @@ -487,7 +489,7 @@ export function McpPage({ projects, mcpOverview, mcpRegistry, refetchMcps, initi // as the paste-textarea path applies server-side. const handleImportFromFile = async (file: File) => { setImportJsonError(null); - let text = ''; + let text: string; try { text = await file.text(); } catch (e) { diff --git a/frontend/src/pages/SettingsPage.css b/frontend/src/pages/SettingsPage.css index c535690c..718d0059 100644 --- a/frontend/src/pages/SettingsPage.css +++ b/frontend/src/pages/SettingsPage.css @@ -254,7 +254,7 @@ min-width: 0; padding: 3px 6px; font-size: var(--kr-fs-xs); - background: var(--kr-bg-input, var(--kr-bg-elev)); + background: var(--kr-bg-input); color: var(--kr-text-primary); border: 1px solid var(--kr-border); border-radius: var(--kr-r-sm); diff --git a/frontend/src/pages/WorkflowsPage.css b/frontend/src/pages/WorkflowsPage.css index cb9de493..5251a439 100644 --- a/frontend/src/pages/WorkflowsPage.css +++ b/frontend/src/pages/WorkflowsPage.css @@ -2046,7 +2046,7 @@ flex: 1 1 180px; min-width: 140px; padding: 3px 8px; - background: var(--kr-bg-input, var(--kr-bg-elev)); + background: var(--kr-bg-input); border: 1px solid var(--kr-border); border-radius: var(--kr-r-sm); color: var(--kr-text-muted); @@ -2090,21 +2090,50 @@ margin-bottom: var(--kr-sp-2); } .wf-bm-spine { stroke: var(--kr-border-medium, var(--kr-border)); stroke-width: 2; } -.wf-bm-node { fill: var(--kr-accent); } +.wf-bm-node { fill: var(--kr-text-primary); } .wf-bm-node-label { fill: var(--kr-text-primary); font-size: 11px; font-family: var(--kr-font-mono, monospace); } -.wf-bm-arc { fill: none; stroke: var(--kr-accent); stroke-width: 1.5; opacity: 0.75; } -.wf-bm-arc-back { stroke: var(--kr-warning); stroke-dasharray: 3 2; } -.wf-bm-head { fill: var(--kr-accent); } -.wf-bm-arc-back + .wf-bm-head, -.wf-branch-map .wf-bm-arc-back { } + +/* Goto arcs — base. Colour = identity (per-arc slot below); dash = backward + loop (redundant with colour → A11y). Arrowhead inherits the arc's stroke via + `context-stroke` so each head matches its line. */ +.wf-bm-arc { fill: none; stroke-width: 1.6; opacity: 0.85; transition: opacity 0.12s, stroke-width 0.12s; } +.wf-bm-arc-back { stroke-dasharray: 4 2.5; } +.wf-bm-head { fill: context-stroke; } + +/* Categorical arc palette — fixed order, CVD-validated per theme (dataviz skill: + dark ΔE 18.3, light ΔE 12.5, --pairs all). Dark = Kronn brand hues; light gets + a distinct validated set (the light brand hues collide under CVD). Slot 'x' = + overflow (>5 arcs) folds to neutral, still disambiguated by geometry + dash. */ +.wf-bm-arc-c0 { stroke: #c8ff00; } +.wf-bm-arc-c1 { stroke: #00d4ff; } +.wf-bm-arc-c2 { stroke: #8b5cf6; } +.wf-bm-arc-c3 { stroke: #ff5bbc; } +.wf-bm-arc-c4 { stroke: #60a5fa; } +.wf-bm-arc-cx { stroke: var(--kr-text-ghost); } +:root[data-theme="light"] .wf-bm-arc-c0, +:root[data-theme="sakura"] .wf-bm-arc-c0 { stroke: #4338ca; } +:root[data-theme="light"] .wf-bm-arc-c1, +:root[data-theme="sakura"] .wf-bm-arc-c1 { stroke: #0891b2; } +:root[data-theme="light"] .wf-bm-arc-c2, +:root[data-theme="sakura"] .wf-bm-arc-c2 { stroke: #be185d; } +:root[data-theme="light"] .wf-bm-arc-c3, +:root[data-theme="sakura"] .wf-bm-arc-c3 { stroke: #c2410c; } +:root[data-theme="light"] .wf-bm-arc-c4, +:root[data-theme="sakura"] .wf-bm-arc-c4 { stroke: #15803d; } + +/* Follow a crossing line: hovering the map dims every arc, hovering one arc (or + its head) restores + thickens it. Pointer-events on the invisible-fill path + are enabled via a fat transparent hit area. */ +.wf-branch-map:hover .wf-bm-arc { opacity: 0.28; } +.wf-branch-map .wf-bm-arc:hover { opacity: 1; stroke-width: 3; } + .wf-branch-map-legend { display: flex; gap: var(--kr-sp-3); margin-top: var(--kr-sp-2); font-size: var(--kr-fs-2xs, 11px); + color: var(--kr-text-muted); } -.wf-bm-legend-fwd { color: var(--kr-accent); } -.wf-bm-legend-back { color: var(--kr-warning); } /* #14 — parent-tick group accordion wrapping its sub-runs' compact rows. */ .wf-run-group { margin-bottom: 4px; } @@ -2165,6 +2194,8 @@ .wf-run-compact[data-status="WaitingApproval"] .wf-run-compact-dot, .wf-run-compact[data-status="StoppedByGuard"] .wf-run-compact-status, .wf-run-compact[data-status="StoppedByGuard"] .wf-run-compact-dot { color: var(--kr-warning); background: var(--kr-warning); } +.wf-run-compact[data-status="Interrupted"] .wf-run-compact-status, +.wf-run-compact[data-status="Interrupted"] .wf-run-compact-dot { color: var(--kr-text-ghost); background: var(--kr-text-ghost); } /* keep the status text coloured but the dot uses background only */ .wf-run-compact-dot { border-radius: 50%; } .wf-run-compact-parent { @@ -4758,3 +4789,30 @@ font-size: var(--kr-fs-xs); font-family: var(--kr-font-mono); } + +/* 0.8.11 — per-step model tier badge (wizard-set), shown next to the agent in + the WF detail + compact pipeline. Muted + small: informative, not shouty. */ +.wf-step-tier { + font-size: var(--kr-fs-2xs, 11px); + color: var(--kr-text-muted); + padding: 1px 6px; + border: 1px solid var(--kr-border-light); + border-radius: 999px; + white-space: nowrap; +} +.wf-pipe-chip-tier { font-size: var(--kr-fs-2xs, 11px); opacity: 0.85; } + +/* 0.8.11 UX — disabled-workflow state made visible next to the inert Lancer. */ +.wf-disabled-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: var(--kr-fs-2xs, 11px); + color: var(--kr-warning); + border: 1px solid rgba(var(--kr-warning-rgb), 0.35); + background: rgba(var(--kr-warning-rgb), 0.08); + border-radius: 999px; + white-space: nowrap; +} +.wf-enable-btn { color: var(--kr-success); border-color: rgba(var(--kr-success-rgb), 0.4); } diff --git a/frontend/src/pages/WorkflowsPage.tsx b/frontend/src/pages/WorkflowsPage.tsx index 1699debc..572b26b1 100644 --- a/frontend/src/pages/WorkflowsPage.tsx +++ b/frontend/src/pages/WorkflowsPage.tsx @@ -1322,6 +1322,7 @@ export function WorkflowsPage({ projects, installedAgentTypes, agentAccess, conf className="wf-small-btn" onClick={(e) => { e.stopPropagation(); handleTrigger(wf.id); }} disabled={!wf.enabled || triggering === wf.id} + title={!wf.enabled ? t('wf.launchDisabledHint') : undefined} > {triggering === wf.id ? : } {t('wf.trigger')} @@ -1383,6 +1384,18 @@ export function WorkflowsPage({ projects, installedAgentTypes, agentAccess, conf onNavigateToWorkflow={(wfId) => openDetail(wfId)} onNavigateToRun={(wfId, runId) => openDetail(wfId, runId)} focusRunId={focusRunId} + onToggleEnabled={async (enabled) => { + // 0.8.11 UX — one-click enable from the detail (a disabled + // workflow's launch button is inert; see WorkflowDetail). + try { + await workflowsApi.update(detailWorkflow.id, { enabled }); + if (toastProp) toastProp(t(enabled ? 'wf.enabledToast' : 'wf.disabledToast'), 'success'); + openDetail(detailWorkflow.id); + refetch(); + } catch (e) { + if (toastProp) toastProp(String(e), 'error'); + } + }} onGateDecided={() => setLiveRun(null)} onExport={async () => { try { diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index a5168381..3f303b0c 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -94,7 +94,7 @@ const makeDiscussion = (id: string, msgCount: number): Discussion => ({ participants: ['ClaudeCode'], messages: [], message_count: msgCount, non_system_message_count: msgCount, - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', diff --git a/frontend/src/pages/__tests__/DiscussionsPage.test.tsx b/frontend/src/pages/__tests__/DiscussionsPage.test.tsx index 4671b61f..0a5393f0 100644 --- a/frontend/src/pages/__tests__/DiscussionsPage.test.tsx +++ b/frontend/src/pages/__tests__/DiscussionsPage.test.tsx @@ -160,7 +160,7 @@ const makeListDiscussion = (id: string, msgCount: number): Discussion => ({ participants: ['ClaudeCode'], messages: [], // list endpoint returns empty messages message_count: msgCount, non_system_message_count: msgCount, // but provides the count - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', @@ -605,7 +605,7 @@ describe('DiscussionsPage', () => { it('archived discussions show count in Archives section header', async () => { const activeDisc: Discussion = { ...makeListDiscussion('d1', 3), - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, }; const archivedDisc: Discussion = { ...makeListDiscussion('d2', 5), @@ -1712,7 +1712,7 @@ describe('DiscussionsPage', () => { { id: 'm1', role: 'User', content: 'Tell me about my project', agent_type: null, timestamp: '2026-01-01T00:00:00Z', tokens_used: 0, auth_mode: null }, ], message_count: 1, non_system_message_count: 1, - archived: false, pinned: false, + archived: false, pinned: false, pin_first_message: false, workspace_mode: 'Direct', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', diff --git a/frontend/src/pages/__tests__/WorkflowsPage.test.tsx b/frontend/src/pages/__tests__/WorkflowsPage.test.tsx index 2056aa16..445ff0ea 100644 --- a/frontend/src/pages/__tests__/WorkflowsPage.test.tsx +++ b/frontend/src/pages/__tests__/WorkflowsPage.test.tsx @@ -578,3 +578,108 @@ describe('WorkflowsPage', () => { confirmSpy.mockRestore(); }); }); + +// ── 0.8.11 UX — launch modal + disabled-state comprehension ───────────────── +// The exact flow that silently failed for a real user: a cloned (disabled) +// workflow's Lancer did nothing, and a variable-less workflow shows no popup. +describe('workflow launch modal + disabled-state UX (0.8.11)', () => { + const labWorkflow = (over: Partial = {}): Workflow => ({ + id: 'wf-lab', + name: 'PR Review LAB', + project_id: null, + trigger: { type: 'Manual' }, + steps: [ + { name: 'prnum', agent: 'ClaudeCode', prompt_template: 'PR {{pr_number}}', mode: { type: 'Normal' } } as never, + // 2 steps → the wizard opens in ADVANCED mode (per-step editor cards); + // a single step falls into simple mode where the tier select isn't shown. + { name: 'reason', agent: 'ClaudeCode', prompt_template: 'Review {{steps.prnum.data.stdout}}', mode: { type: 'Normal' } } as never, + ], + actions: [], + safety: { sandbox: false, max_files: null, max_lines: null, require_approval: false }, + workspace_config: null, + concurrency_limit: null, + variables: [{ + name: 'pr_number', label: 'N° de la PR à reviewer', placeholder: '1800', + description: null, required: true, + }], + enabled: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...over, + } as Workflow); + + const labSummary = (over: Partial = {}): WorkflowSummary => ({ + id: 'wf-lab', name: 'PR Review LAB', project_id: null, project_name: null, + trigger_type: 'manual', step_count: 1, misconfigured_step_count: 0, + enabled: true, last_run: null, created_at: '2026-01-01T00:00:00Z', ...over, + }); + + it('Lancer sur un WF à variables ouvre la popup, bloque les requis vides, puis déclenche avec les valeurs', async () => { + mockWorkflowsApi.list.mockResolvedValue([labSummary()]); + mockWorkflowsApi.get.mockResolvedValue(labWorkflow()); + mockWorkflowsApi.listRuns.mockResolvedValue([]); + mockWorkflowsApi.triggerStream.mockResolvedValue(undefined); + + await wrap(); + + // Click the card's Lancer (list-level trigger). + const lancer = screen.getAllByText('Lancer')[0]; + await act(async () => { fireEvent.click(lancer); }); + + // The launch modal opens with the declared variable field + required star. + await waitFor(() => expect(screen.getByText('N° de la PR à reviewer')).toBeInTheDocument()); + const input = screen.getByPlaceholderText('1800'); + + // Submit with the required field EMPTY → inline error, no trigger fired. + const goButtons = screen.getAllByText('Lancer'); + const modalGo = goButtons[goButtons.length - 1]; + await act(async () => { fireEvent.click(modalGo); }); + expect(screen.getByText(/obligatoire/i)).toBeInTheDocument(); + expect(mockWorkflowsApi.triggerStream).not.toHaveBeenCalled(); + + // Fill + submit → modal closes and the trigger fires with the value. + fireEvent.change(input, { target: { value: '1800' } }); + await act(async () => { fireEvent.click(modalGo); }); + await waitFor(() => expect(mockWorkflowsApi.triggerStream).toHaveBeenCalled()); + const call = mockWorkflowsApi.triggerStream.mock.calls[0]; + expect(call[0]).toBe('wf-lab'); + expect(call.some((a: unknown) => !!a && typeof a === 'object' && (a as Record).pr_number === '1800')).toBe(true); + expect(screen.queryByText('N° de la PR à reviewer')).toBeNull(); + }); + + it('carte désactivée : Lancer est inerte MAIS porte le tooltip explicatif', async () => { + mockWorkflowsApi.list.mockResolvedValue([labSummary({ enabled: false })]); + await wrap(); + + const lancer = screen.getAllByText('Lancer')[0].closest('button'); + expect(lancer).toBeDisabled(); + expect(lancer?.getAttribute('title')).toMatch(/désactivé/i); + }); + + it('wizard : chaque step Agent expose le sélecteur de palier (⚡/🎯/🧠) et le changement est appliqué', async () => { + mockWorkflowsApi.list.mockResolvedValue([labSummary()]); + mockWorkflowsApi.get.mockResolvedValue(labWorkflow()); + mockWorkflowsApi.listRuns.mockResolvedValue([]); + + await wrap(); + await act(async () => { fireEvent.click(screen.getByText('PR Review LAB')); }); + await waitFor(() => expect(screen.getByText('Éditer')).toBeDefined()); + await act(async () => { fireEvent.click(screen.getByText('Éditer')); }); + + // Navigate to the Steps stage — adaptive: click "Suivant" until the step + // editor (name input 'prnum') is visible (a declared-variables workflow can + // add a stage vs the plain flow). + for (let i = 0; i < 5 && !screen.queryByDisplayValue('prnum'); i++) { + const next = screen.queryAllByText('Suivant'); + if (!next.length) break; + await act(async () => { fireEvent.click(next[next.length - 1]); }); + } + await waitFor(() => expect(screen.getByDisplayValue('prnum')).toBeInTheDocument()); + + const tierSelect = screen.getAllByTitle(/Palier de modèle/)[0] as HTMLSelectElement; + expect(tierSelect.value).toBe('default'); + expect(Array.from(tierSelect.options).map(o => o.value)).toEqual(['economy', 'default', 'reasoning']); + fireEvent.change(tierSelect, { target: { value: 'reasoning' } }); + expect(tierSelect.value).toBe('reasoning'); + }); +}); diff --git a/frontend/src/styles/__tests__/tokens-defined.test.ts b/frontend/src/styles/__tests__/tokens-defined.test.ts new file mode 100644 index 00000000..ecef4872 --- /dev/null +++ b/frontend/src/styles/__tests__/tokens-defined.test.ts @@ -0,0 +1,56 @@ +// Guard against "phantom" CSS custom properties: a `var(--kr-…)` used somewhere +// in src/ that is never defined in tokens.css. An undefined var without a +// fallback makes the browser DROP the whole declaration → text turns black on a +// dark background, borders/backgrounds vanish, silently. The 2026-07 audit found +// ~20 of these; this test keeps the count at zero (see CHANGELOG 0.8.11). +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function walk(dir: string, exts: string[]): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist') continue; + const p = join(dir, name); + const s = statSync(p); + if (s.isDirectory()) out.push(...walk(p, exts)); + else if (exts.some(e => name.endsWith(e))) out.push(p); + } + return out; +} + +function definedVars(): Set { + const defined = new Set(); + for (const f of walk(SRC, ['.css'])) { + const txt = readFileSync(f, 'utf8'); + for (const m of txt.matchAll(/(--kr-[a-z0-9-]+)\s*:/g)) defined.add(m[1]); + } + return defined; +} + +/** Every `var(--kr-…)` usage WITHOUT a fallback, with its file. */ +function usagesWithoutFallback(): { name: string; file: string }[] { + const out: { name: string; file: string }[] = []; + for (const f of walk(SRC, ['.css', '.tsx', '.ts'])) { + if (f.endsWith('tokens-defined.test.ts')) continue; + const txt = readFileSync(f, 'utf8'); + // var(--kr-xxx) or var(--kr-xxx, fallback) — capture the name and whether a comma follows. + for (const m of txt.matchAll(/var\(\s*(--kr-[a-z0-9-]+)\s*(,)?/g)) { + if (!m[2]) out.push({ name: m[1], file: f.replace(SRC, 'src') }); + } + } + return out; +} + +describe('CSS design tokens', () => { + it('has no phantom --kr-* var used without a fallback and without a definition', () => { + const defined = definedVars(); + const offenders = usagesWithoutFallback().filter(u => !defined.has(u.name)); + const unique = [...new Map(offenders.map(o => [`${o.name}@${o.file}`, o])).values()]; + expect(unique, `Undefined --kr-* vars (define in tokens.css or add a fallback):\n` + + unique.map(o => ` ${o.name} (${o.file})`).join('\n')).toEqual([]); + }); +}); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 70795e23..117ae860 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -161,6 +161,38 @@ --kr-text-glow-lime: 0 0 8px rgba(200, 255, 0, 0.45), 0 0 18px rgba(200, 255, 0, 0.20); --kr-text-glow-magenta: 0 0 8px rgba(255, 27, 168, 0.45), 0 0 18px rgba(255, 27, 168, 0.20); --kr-bevel-top: inset 0 1px 0 rgba(255, 255, 255, 0.04); + + /* ── Compat aliases (0.8.11) ────────────────────────────────────────────── + Tokens that call sites referenced without a definition (the "phantom vars" + the 2026-07 audit found — undefined `var()` silently drops the property). + Defined ONCE here as references to the canonical tokens: because custom- + property references resolve at use-time against the ACTIVE theme, a light/ + matrix/… theme that overrides the canonical token automatically flows + through these aliases too — no per-theme duplication needed. + A vitest guard (tokens-defined.test.ts) fails the build if a new phantom + var appears. */ + --kr-danger: var(--kr-error); + --kr-border-faint: var(--kr-border-ghost); + --kr-border-soft: var(--kr-border-light); + --kr-border-subtle: var(--kr-border-light); + --kr-radius-sm: var(--kr-r-sm); + --kr-radius-md: var(--kr-r-md); + --kr-radius-pill: 999px; + --kr-r-pill: 999px; + --kr-bg: var(--kr-bg-base); + --kr-bg-card: var(--kr-bg-elevated); + --kr-bg-card-subtle: var(--kr-bg-subtle); + --kr-code-bg: var(--kr-bg-code); + --kr-bg-accent-soft: rgba(var(--kr-accent-rgb), 0.10); + --kr-bg-accent-subtle: rgba(var(--kr-accent-rgb), 0.06); + --kr-info-bg: rgba(var(--kr-info-rgb), 0.12); + --kr-success-bg: rgba(var(--kr-success-rgb), 0.12); + --kr-warning-bg: rgba(var(--kr-warning-rgb), 0.12); + /* RGB triples for rgba(var(--…-rgb), α) overlays — need real triples, not a + ref. Dark base = light text; the light theme overrides these below. */ + --kr-text-rgb: 232, 234, 237; + --kr-text-faint-rgb: 255, 255, 255; + --kr-text-ghost-rgb: 255, 255, 255; } /* ═══════════════════════════════════════════════════════════════════════════ @@ -291,6 +323,12 @@ --kr-shadow-xl: 0 8px 16px rgba(16, 24, 40, 0.10), 0 30px 60px rgba(16, 24, 40, 0.18); --kr-shadow-popover: 0 4px 12px rgba(16, 24, 40, 0.10), 0 12px 28px rgba(16, 24, 40, 0.12); --kr-shadow-modal: 0 8px 20px rgba(16, 24, 40, 0.12), 0 20px 40px rgba(16, 24, 40, 0.18); + + /* 0.8.11 — dark-text RGB triples for rgba() overlays (light theme has dark + text on a light base, so the dark-default white triples would be invisible). */ + --kr-text-rgb: 26, 29, 35; + --kr-text-faint-rgb: 10, 14, 30; + --kr-text-ghost-rgb: 10, 14, 30; } /* ═══════════════════════════════════════════════════════════════════════════ diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 55ddf3e2..aab26196 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -19,6 +19,18 @@ configure({ asyncUtilTimeout: 5000 }); // of undefined (reading 'getItem')". Install a deterministic in-memory Storage // when the ambient one is missing/non-functional. No-op where happy-dom already // provides a working store; `configurable`/`writable` so specs can still spy. +// 0.8.11 (D10) — getUILocale() now follows the browser's language when no +// locale is stored. The test runner's navigator.language is 'en-US', which would +// flip every French-asserting component test to English. Pin the browser locale +// to French in the test env so those assertions stay deterministic (and the +// "default French" locale tests keep their intent). Robust against +// localStorage.clear() (unlike seeding storage). Real browser detection is +// covered by src/lib/__tests__/i18n.test.ts via detectBrowserLocale(). +try { + Object.defineProperty(navigator, 'language', { value: 'fr-FR', configurable: true }); + Object.defineProperty(navigator, 'languages', { value: ['fr-FR', 'fr'], configurable: true }); +} catch { /* some envs freeze navigator — best effort */ } + function makeMemoryStorage(): Storage { let store = new Map(); return { diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index be56ae3e..3401288c 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -1,6 +1,11 @@ // ╔═══════════════════════════════════════════════════════════════════════════╗ -// ║ AUTO-GENERATED by ts-rs — do not edit manually ║ -// ║ Run `make typegen` to regenerate from Rust models ║ +// ║ SEMI-GENERATED — mirrors the Rust models (ts-rs bindings), with ║ +// ║ deliberate frontend simplifications (u64→number, some optionals, widened ║ +// ║ JsonValue). NOT auto-overwritten. When you add/change a Rust model field: ║ +// ║ • run `make typegen` — regenerates backend/bindings/ and FAILS if a ║ +// ║ field here drifts from the model (scripts/check-types-drift.mjs). ║ +// ║ • add the missing field(s) from backend/bindings/.ts. ║ +// ║ CI runs the same drift guard, so forgetting a field fails the build. ║ // ╚═══════════════════════════════════════════════════════════════════════════╝ // ─── Config ───────────────────────────────────────────────────────────────── @@ -11,16 +16,38 @@ export interface AppConfig { scan: ScanConfig; agents: AgentsConfig; language: string; + ui_language: string; + stt_model?: string | null; + tts_voices?: Record; + disabled_agents: Array; } export interface ServerConfig { host: string; port: number; + domain: string | null; + failure_notify_url: string | null; + run_retention_days: number; + max_concurrent_agents: number; + agent_stall_timeout_min: number; + pseudo: string | null; + avatar_email: string | null; + bio: string | null; + global_context: string | null; + global_context_mode: string; + anti_hallucination_mode: string; + continual_learning_enabled: boolean; + debug_mode: boolean; + default_model_tier: ModelTier; + default_summary_strategy: SummaryStrategy; } export interface TokensConfig { keys: ApiKey[]; disabled_overrides: string[]; + anthropic?: string | null; + openai?: string | null; + google?: string | null; } export interface ApiKey { @@ -345,6 +372,7 @@ export interface Project { linked_repos?: LinkedRepo[]; created_at: string; // ISO 8601 updated_at: string; + default_profile_id?: string | null; } /** A companion repository linked to a project (0.8.3). The @@ -769,6 +797,8 @@ export interface WorkflowStep { * the step's agent runs, a shared discussion is opened and a second agent is * invited to debate the output until agreement. */ multi_agent_review?: MultiAgentReviewConfig | null; + gate_checkpoint_before?: boolean | null; + gate_auto_approve_after_secs?: number | null; } /** Config for the "Multi-agent review" option on an Agent step. */ @@ -909,6 +939,7 @@ export interface WorkflowRun { * because their HEAD held commits not on any known base. Empty when * nothing was preserved (most cases). */ produced_branches?: ProducedBranch[]; + state?: Record; } export interface ProducedBranch { @@ -918,7 +949,7 @@ export interface ProducedBranch { pushed_upstream: boolean; } -export type RunStatus = "Pending" | "Running" | "Success" | "Failed" | "Cancelled" | "WaitingApproval" | "StoppedByGuard"; +export type RunStatus = "Pending" | "Running" | "Success" | "Failed" | "Cancelled" | "WaitingApproval" | "StoppedByGuard" | "Interrupted"; /** 0.7.0 Phase 4 — payload for POST /api/workflows/:id/runs/:run_id/decide. */ export interface DecideRunRequest { @@ -1048,6 +1079,7 @@ export interface CreateWorkflowRequest { exec_allowlist?: string[]; /** 0.6.0 UX pass — manual launch variables. */ variables?: PromptVariable[]; + enabled?: boolean | null; } // 0.8.3 — Bundle endpoint types (atomic Workflow + QP + QA + CustomAPI). @@ -1118,6 +1150,7 @@ export interface WorkflowExportEnvelope { exported_at: string; workflow: Workflow; referenced_quick_prompts?: QuickPrompt[]; + referenced_workflows?: Array; } /** Self-contained envelope returned by GET /api/quick-prompts/:id/export. */ @@ -1377,6 +1410,7 @@ export interface Discussion { test_mode_stash_ref?: string | null; created_at: string; // ISO 8601 updated_at: string; + pin_first_message: boolean; } /** Auto-summary policy. 0.8.6 phase 4 — used both per-discussion @@ -1401,6 +1435,9 @@ export interface DiscussionMessage { author_avatar_email?: string | null; /** 0.8.7 anti-hallucination P2 lint report for this agent message. */ lint_report?: LintReport | null; + cost_usd?: number | null; + source_msg_id?: string | null; + duration_ms?: number | null; } export type MessageRole = "User" | "Agent" | "System";