lopi-ui 0.22.0

TUI dashboard and web API for lopi
use serde::{Deserialize, Serialize};

pub(super) const MAX_GOAL_LENGTH: usize = 2000;

/// Reject goal text carrying C0/C1 control characters other than ordinary
/// `\n`/`\r`/`\t` whitespace — NUL and ANSI escape sequences have no place
/// in a natural-language goal and are a log-poisoning/injection vector (see
/// `.claude/rules/security.md`). Shared by every handler that persists a
/// goal string (`handlers::validate_goal`, `ScheduleBody::validate`,
/// `MaxxBody::validate`, `ScheduleChainBody::validate`'s per-step check) so
/// a scheduled/MAXX/chain path can't reopen the vector `POST /api/tasks`
/// closes.
pub(super) fn reject_control_chars(goal: &str) -> Result<(), String> {
    if let Some(c) = goal
        .chars()
        .find(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
    {
        return Err(format!(
            "goal contains a disallowed control character (U+{:04X})",
            c as u32
        ));
    }
    Ok(())
}

/// Request body for `POST /api/tasks`.
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
    /// Natural-language goal for the agent (max 2 000 chars).
    pub goal: String,
    /// Path to the git repository the agent should work in.
    pub repo: Option<String>,
    /// Task priority: `"low"`, `"normal"` (default), `"high"`, or `"critical"`.
    #[serde(default)]
    pub priority: Option<String>,
    /// Additional constraints appended to the agent's system prompt.
    #[serde(default)]
    pub constraints: Option<Vec<String>>,
    /// Directories the agent is permitted to read and write.
    #[serde(default)]
    pub allowed_dirs: Option<Vec<String>>,
    /// Directories the agent must not touch.
    #[serde(default)]
    pub forbidden_dirs: Option<Vec<String>>,
    /// Maximum retry attempts before the task is marked failed.
    #[serde(default)]
    pub max_retries: Option<u8>,
    /// Phase 11 — require human approval of the plan before implementation.
    #[serde(default)]
    pub require_plan_approval: Option<bool>,
    /// Verifier as Explicit Gate — force the Konjo Verifier second-score pass
    /// for this task, independent of `autonomy_level`. Mirrors
    /// [`lopi_core::Task::verifier_required`].
    #[serde(default)]
    pub verifier_required: Option<bool>,
    /// Explicit verifier model override, e.g. `"claude-opus-4-7"`. Mirrors
    /// [`lopi_core::Task::verifier_model`].
    #[serde(default)]
    pub verifier_model: Option<String>,
    /// Reasoning-effort hint for the verifier's grading pass. Mirrors
    /// [`lopi_core::Task::verifier_effort`].
    #[serde(default)]
    pub verifier_effort: Option<String>,
    /// Report on Finish channel name (e.g. `"telegram"`). Validated via
    /// [`lopi_core::ReportChannel::parse`] at request time — an unknown or
    /// currently-unreachable channel (`"whatsapp"`) is rejected with a 422,
    /// never silently dropped. Mirrors [`lopi_core::Task::report`].
    #[serde(default)]
    pub report: Option<String>,
    /// Per-task override of the hard iteration ceiling, taking precedence
    /// over the repo's `.lopi/loop.toml`. `0` is the infinite-loop sentinel.
    /// Mirrors [`lopi_core::Task::max_iterations`].
    #[serde(default)]
    pub max_iterations: Option<u8>,
    /// Explicit worker-model override. Mirrors [`lopi_core::Task::model`].
    #[serde(default)]
    pub model: Option<String>,
    /// Reasoning-effort level for the worker session, passed to the CLI as
    /// `--effort` (`low`/`medium`/`high`/`xhigh`/`max`). Mirrors
    /// [`lopi_core::Task::effort`].
    #[serde(default)]
    pub effort: Option<String>,
    /// How much the `claude -p` worker session may act on tool calls without
    /// a human answering a prompt (`"bypassPermissions"` / `"auto"` /
    /// `"acceptEdits"` / `"dontAsk"`), passed to the CLI as
    /// `--permission-mode`. Validated via
    /// [`lopi_core::PermissionMode::parse`] at request time — an unrecognized
    /// value is rejected with a 422, never silently dropped or coerced.
    /// Unlike `autonomy` on the web wire type, this one is wired end to end:
    /// it reaches a real `--permission-mode` subprocess arg, not just
    /// client-side state. Mirrors [`lopi_core::Task::permission_mode`].
    #[serde(default)]
    pub permission_mode: Option<String>,
    /// Goal-intent override for zero-diff success handling: `"file_changes"`
    /// (a zero-diff attempt fails and retries) or `"review_only"` (zero diff
    /// is a valid success). `None` infers it from the goal text. Mirrors
    /// [`lopi_core::Task::deliverable`].
    #[serde(default)]
    pub deliverable: Option<lopi_core::Deliverable>,
    /// Guardrail precondition — a shell command that must exit `0` before
    /// the loop's first iteration. Mirrors [`lopi_core::Task::gate`].
    #[serde(default)]
    pub gate: Option<String>,
    /// Guardrail exit-condition — a shell command checked after each
    /// iteration; exiting `0` ends the loop early as a success. Mirrors
    /// [`lopi_core::Task::until`].
    #[serde(default)]
    pub until: Option<String>,
    /// On-fail policy override (`"stop"` / `"continue"` / `"backoff"`).
    /// Mirrors [`lopi_core::Task::on_fail`].
    #[serde(default)]
    pub on_fail: Option<lopi_core::loop_config::OnFail>,
    /// Backend-1 — opaque caller-supplied identity (e.g. a loop-stack card
    /// id), persisted and echoed back verbatim. Mirrors
    /// [`lopi_core::Task::client_ref`].
    #[serde(default)]
    pub client_ref: Option<String>,
    /// Eval-Execution-1 (A1) — the machine-checkable success condition the
    /// tiered eval executor scores this loop against. Compiled UI-side from a
    /// card's `evals` checklist. Mirrors [`lopi_core::Task::acceptance`].
    #[serde(default)]
    pub acceptance: Option<lopi_core::acceptance::Acceptance>,
    /// Eval-Execution-1 (A1) — operator opt-out of the fail-closed verifier.
    /// `false` / omitted keeps the safe default (an error blocks finalize).
    /// Mirrors [`lopi_core::Task::verifier_fail_open`].
    #[serde(default)]
    pub verifier_fail_open: Option<bool>,
    /// Progress-Gating (A3) — per-task token budget ceiling the loop meters
    /// against, stopping with `StopReason::Budget` on exceed. `0`/omitted
    /// inherits the repo/global budget. Mirrors [`lopi_core::Task::budget_tokens`].
    #[serde(default)]
    pub budget_tokens: Option<u64>,
    /// Per-card budget override, taking precedence over the target repo's
    /// `.lopi/loop.toml` `[budget]`. A `preset` of `"quick"`/`"standard"`
    /// caps spend and **denies the `Workflow`/`Task`/`Agent` fan-out tools**
    /// (so sub-agents can't run up cost), while `"deep"`/`"unlimited"` allow
    /// fan-out; `usd`/`tokens` cap spend directly. This is the card-level
    /// lever to stop a cheap-model card from fanning out into pricier
    /// sub-agents. Mirrors [`lopi_core::Task::budget_override`].
    #[serde(default)]
    pub budget_override: Option<lopi_core::BudgetOverride>,
}

/// Response body for `POST /api/tasks`.
#[derive(Debug, Serialize)]
pub struct CreateTaskResponse {
    /// UUID of the created (or existing) task. When `duplicate_of` is set,
    /// this is the id generated for *this* request, which was never
    /// actually queued — the task genuinely running is `duplicate_of`.
    pub id: String,
    /// The goal string as stored.
    pub goal: String,
    /// `true` if the task was newly queued; `false` if it was a duplicate.
    pub queued: bool,
    /// If this was a duplicate, the ID of the existing task actually
    /// running — callers that need "the real task id" must prefer this
    /// over `id` when it's set.
    pub duplicate_of: Option<String>,
    /// Echoes `CreateTaskRequest::client_ref` verbatim, so a caller that
    /// fired several requests concurrently can still line up which
    /// response belongs to which request without relying on ordering.
    pub client_ref: Option<String>,
}