polyc-query 2026.9.0

Read layer over the event log: a DataFusion engine for SQL over replayed partitions, and a per-conversation Parquet projection for participation-scoped search.
//! The `routines` reference table's caller-supplied data source (issue
//! #1592, part of the routine conversational management PRD #1482).
//!
//! [`RoutineStatusRecord`] is deliberately a PLAIN, proto-free struct rather
//! than this crate reusing `polyc_controller::Routine` directly the way
//! `crate::engine::ReferenceData::personas` reuses
//! `polyc_proto::proto::polychrome::persona::v1::PersonaProfile`: unlike
//! `polyc-proto` (a foundation this crate already depends on),
//! `polyc-controller` is a full CRD-reconciler crate that itself pulls in
//! `polyc-agent`/`polyc-llm`/`kube` — depending on it
//! directly here would drag this crate's whole isolation boundary through
//! nearly the rest of the application. [`RoutineCatalog`] is the seam that keeps
//! that dependency out: the control plane (which already depends on
//! `polyc-controller`) implements this trait over its own
//! `kube::Api<Routine>`/`RoutineStatus`, converting each `Routine` into a
//! [`RoutineStatusRecord`] BEFORE it ever reaches this crate — the identical
//! "the caller resolves already-typed values, this crate only decodes"
//! division of labor `crate::engine::PartitionEvents`'s own doc states for
//! journal replay and `crate::engine::ReferenceData`'s doc states for
//! persona/participation resolution.
//!
//! # Status surface plus provenance/pause/schedule (#1592 → #1593)
//!
//! [`RoutineStatusRecord`] carried ONLY the routine's STATUS fields through
//! #1592 — `ready`, `phase`, `message`, `last_fire_time`, `next_fire_time`,
//! `conditions_json` — deliberately REBIND-PROOF against the concurrent
//! payload/spec reshape #1591: a typed table decoding zero spec fields
//! cannot be broken by a spec-shape change landing in parallel. #1591 has
//! since landed and the CRD schema stabilized, so #1593 — the slice that
//! DOES render spec fields — adds exactly the spec surface the routines
//! explorer page's provenance/pause-state/schedule sections need:
//! `creator_persona`/`provenance_conversation_id` (`RoutineProvenance`),
//! `suspended`/`paused_by`/`paused_at`/`pause_reason` (`RoutineSuspend`, via
//! `RoutineSpec::suspend`'s presence), and `schedule_json`/`next_fires_json`
//! (`RoutineSchedule`, plus the compiled next-fire instants — computed by
//! the caller via `polyc_controller::routine_next_fire::next_n_fires_after`,
//! the SAME shared callable the approval-preview and `routine_list` chat
//! tool already use, never reimplemented here). There is no run-as field to
//! surface: since #1802 a routine's fire principal is its owner
//! (`RoutineProvenance::creator_persona`) by rule, not a resolution a query
//! workload could read separately.
//!
//! # Prompt (the routines explorer table's row-hierarchy fix)
//!
//! `prompt` (`RoutinePayload::prompt`) was deliberately left out of #1593 —
//! at the time, no query workload needed a routine's prompt text. The
//! routines explorer table's row now leads with the prompt (primary text,
//! two-line-clamped) and demotes the routine name to a secondary sub-label,
//! so this record adds exactly that one field. Like every other spec field
//! above, it rides the Fleet-only gate — see
//! `crate::engine::registration::register_reference_tables`'s call site.
//!
//! `conditions_json`/`schedule_json`/`next_fires_json` all store their JSON
//! shape as text, the same "JSON-shaped string column" convention
//! `crate::decode::handoffs`'s `allowed` column already established for a
//! field this crate has no reason to model as a native Arrow list/struct
//! type.

use std::fmt;

/// One routine's STATUS-ONLY surface — see the module doc's "Status surface
/// ONLY" section for exactly which fields this is (and, just as
/// deliberately, is not).
#[derive(Debug, Clone)]
pub struct RoutineStatusRecord {
    /// The `Routine` resource's own name — identity (`.metadata.name`), never
    /// a spec field.
    pub name: String,
    /// The `Routine` resource's own stable Kubernetes `uid` (empty when the
    /// CR has not yet been assigned one — see `Routine::uid`'s own caller for
    /// when that can happen). The query layer's owner-scoped `fires` view
    /// joins on this rather than on `name` (INV-OAF18's uid-churn fix, part
    /// of issue #1882's review): a routine's name can be deleted and
    /// recreated by a DIFFERENT owner, which a name-keyed join would let the
    /// new owner's session see through as the old owner's fire history — see
    /// `crate::views::FIRES_OWNED_VIEW_SQL`'s own doc.
    pub uid: String,
    /// The routine's synthetic prompt-fire conversation id —
    /// computed by the CALLER from [`Self::uid`] via the control plane's own
    /// derivation (`routine_scheduler::prompt_fire::routine_fire_conversation_id`,
    /// a pinned-namespace `UUIDv5`), never re-derived in this crate: the same
    /// "the caller resolves already-typed values, this crate only decodes"
    /// division of labor the module doc states. Empty when [`Self::uid`] is
    /// empty (a CR with no assigned uid has no fire conversation yet). The
    /// `routine_grants` view joins the fire conversation's `approvals_raw`
    /// rows back to their routine through this column — see
    /// `crate::views::ROUTINE_GRANTS_VIEW_SQL`.
    pub fire_conversation_id: String,
    /// `RoutineStatus::ready`.
    pub ready: bool,
    /// `RoutineStatus::phase` (`"Ready"`/`"Degraded"`), `None` before the
    /// first reconcile.
    pub phase: Option<String>,
    /// `RoutineStatus::message`.
    pub message: Option<String>,
    /// `RoutineStatus::last_fire_time`, epoch ms. `None` before the
    /// routine's first fire.
    pub last_fire_time_ms: Option<i64>,
    /// `RoutineStatus::next_fire_time`, epoch ms. `None` when the routine
    /// isn't (or isn't yet) scheduler-managed, its schedule can never
    /// match, or (for a `once` schedule) it has already fired.
    pub next_fire_time_ms: Option<i64>,
    /// `RoutineStatus::conditions`, JSON-array text — see the module doc.
    pub conditions_json: String,
    /// `RoutineProvenance::creator_persona` (#1593) — the persona id of
    /// whoever asked the agent to create this routine. Immutable, API-server
    /// enforced (INV-RL7).
    pub creator_persona: String,
    /// `RoutineProvenance::conversation_id` (#1593) — the id of the
    /// conversation the create request was compiled from.
    pub provenance_conversation_id: String,
    /// The compiled `RoutineSchedule`, as JSON text: `{"kind":"cron",
    /// "expression":"...","timezone":"..."}` or `{"kind":"once","at_ms":...}`.
    /// `at_ms` is epoch ms, `null` if the source text fails to parse. This
    /// shape diverges on purpose from `routine_nav::render_schedule`, which
    /// the `routine_list` chat tool reads directly. Do not unify the two.
    pub schedule_json: String,
    /// Up to the next three fire instants, epoch ms, as a JSON-array of
    /// numbers, computed FRESH by the caller via
    /// `polyc_controller::routine_next_fire::next_n_fires_after` — the same
    /// shared callable and "up to three" convention the approval preview
    /// (`RoutinePreview::next_fires`) and `routine_list`'s own
    /// `NEXT_FIRES_PREVIEW` already use (#1593). `"[]"` when the schedule
    /// yields none (a `once` schedule already fired, or a `cron` expression
    /// that can never match again) — never absent.
    pub next_fires_json: String,
    /// `RoutineSpec::suspend.is_some()` (#1593) — INV-RL8's entire suspend
    /// signal; see `RoutineSuspend`'s own doc.
    pub suspended: bool,
    /// `RoutineSuspend::paused_by` (#1593), `None` while active.
    pub paused_by: Option<String>,
    /// `RoutineSuspend::paused_at`, epoch ms. `None` while active.
    pub paused_at_ms: Option<i64>,
    /// `RoutineSuspend::reason` (#1593), `None` while active OR when the
    /// pauser gave no reason.
    pub pause_reason: Option<String>,
    /// `RoutineSpec::scope` (#1807), rendered as `"public"`/`"private"` —
    /// the same lowercase wire text the CRD's `camelCase` serde rename
    /// already produces for the enum, mirroring `routine_nav::render_routine`'s
    /// own `"scope"` field rather than inventing a second text shape.
    pub scope: String,
    /// Whether this routine's scheduler-written `Orphaned` status condition
    /// is currently `True` (#1804, surfaced here per #1807) — its owner has
    /// left the instance, so the scheduler skips every tick until it is
    /// duplicated by a new owner or deleted by an admin. Derived, like
    /// `ready`/`phase`, from `.status.conditions`, never a second
    /// independently-tracked flag.
    pub orphaned: bool,
    /// `RoutinePayload::prompt` — the prompt text run at fire time. A SPEC
    /// field, so it rides the exact same Fleet-scope-only gate the rest of
    /// this record's spec surface (`schedule_json`, the provenance/suspend
    /// fields above) already does — see `crate::engine::registration`'s
    /// `register_reference_tables` call site, guarded on
    /// `QueryScope::Fleet` — never resolvable for a non-Fleet session. The
    /// routines explorer page's row hierarchy (issue reported alongside
    /// #1592/#1593) renders this as the row's primary text, the routine
    /// name demoted to a secondary sub-label beneath it.
    pub prompt: String,
    /// `RoutineSpec::display_name` — a short, owner-chosen title. Empty when
    /// the owner named none (never absent — mirrors `prompt`'s non-null
    /// convention).
    pub display_name: String,
    /// `RoutineSpec::description` — a 1-2 sentence summary of what the
    /// routine does. Empty when the owner gave none.
    pub description: String,
    /// The routine's schedule's own IANA zone name, resolved by the caller —
    /// the schedule's `timezone` field, or `"UTC"` when unset. One rule for
    /// both schedule kinds; never empty.
    pub schedule_timezone: String,
}

/// [`RoutineCatalog::list_routines`] failed.
///
/// The control plane's own kube read errored, or (for a deployment with no
/// routine catalog wired at all) the caller never had a catalog to ask.
/// Deliberately a plain, opaque message: this crate logs it
/// (`tracing::warn!`) and builds an EMPTY `routines` table rather than
/// failing the whole Fleet query, the same lenient,
/// one-bad-source-must-not-fail-the-whole-query posture
/// `crate::authority::ScopedQuery::resolve_reference_data` already gives an
/// unavailable persona store.
#[derive(Debug, Clone)]
pub struct RoutineCatalogError(pub String);

impl fmt::Display for RoutineCatalogError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "routine catalog unavailable: {}", self.0)
    }
}

impl std::error::Error for RoutineCatalogError {}

/// Supplies the Fleet `routines` reference table's rows.
///
/// Resolved FRESH on every call — never cached, mirroring
/// `crate::engine::ReferenceData`'s own `personas`/`participations`
/// resolution (`crate::authority::ScopedQuery::resolve_reference_data` calls
/// `PersonaHost::reference_snapshot` fresh per candidate persona per Fleet
/// query, never a cached result — see that type's own doc). The control
/// plane's own implementation wraps `kube::Api<Routine>::list` — see the
/// module doc for why that type never appears in this crate's own
/// signature.
#[async_trait::async_trait]
pub trait RoutineCatalog: Send + Sync {
    /// List every routine's current status surface.
    ///
    /// # Errors
    ///
    /// Returns [`RoutineCatalogError`] if the underlying catalog read fails —
    /// the caller (`crate::authority::ScopedQuery::resolve_reference_data`)
    /// logs it and builds an empty `routines` table rather than failing the
    /// query.
    async fn list_routines(&self) -> Result<Vec<RoutineStatusRecord>, RoutineCatalogError>;
}