Skip to content

Inference Admin Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: JWT/admin surface to register LLM presets as inference_models, queue and monitor per-model inference jobs from the admin UI, plus per-model auto_queue that enqueues one job per session close.

Architecture: New JWT route layer routes/inference_admin.rs (mounted beside /inference-ops/*) reusing existing repo::inference_model / repo::inference_job functions; one migration (auto_queue column + live-job dedup partial unique index); a services/auto_inference.rs helper called from the single ingest_session::close call site in ingest/manager.rs; new admin InferencePage + a "request analysis" action on the session detail page.

Tech Stack: Rust 2024 / Axum 0.8 / SQLx 0.8, React 19 + TS + TailwindCSS 4 (admin frontend only).

Spec: docs/superpowers/specs/2026-07-25-inference-admin-design.md

Global Constraints

  • English-only code/comments/docs; commits GPG-signed (git commit -S), Conventional Commits + gitmoji, one commit per task, NO Co-Authored-By. After each commit: ~/flash-shared/gitminer-cuda/mine_commit.sh 7, then git pull --rebase && git push.
  • Migration prefixes strictly monotonic; verify ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort -c (silent = OK) and re-check the newest prefix immediately before creating the file.
  • Cargo: prefix EVERY cargo command with CARGO_TARGET_DIR=/private/tmp/xls-sdd-target (flash-shared target/ hangs rustc); run cargo jobs serially. Integration tests are #[ignore]d docker tests — run against the test Postgres if reachable, else compile-gate (cargo check --tests) and say so.
  • Frontend: npx tsc -b --noEmit + npx vite build in BOTH frontend/ and frontend-app/; i18n keys in BOTH en and ko blocks (keyParity test); no emoji in UI (inline SVG only); inputs ≥16px font.
  • RBAC: model CRUD + auto_queue = SuperAdmin; job list/submit/cancel = FacilityAdmin; cross-facility = 403 (no tenancy-hiding 404 on this surface).
  • Auto-queue: best-effort only — never block or fail the session close path; auto jobs priority = -10.
  • Deploy at the end only: bash scripts/deploy.sh to api.xylolabs.com + post-deploy verification.
  • Line numbers in this plan are anchors — match on the quoted code.

Task 1: Schema + auto_queue threading (model/DTO/repo/internal routes)

Everything data-layer, with the internal routes compiling against the new field. No behavior change for existing callers (new DTO fields optional).

Files: - Create: crates/xylolabs-db/migrations/20260725100000_inference_models_auto_queue.sql (bump prefix if a newer one exists) - Modify: crates/xylolabs-core/src/models/inference_model.rs (~line 49 InferenceModel) - Modify: crates/xylolabs-core/src/dto/inference_model.rs (Create ~line 8, Update ~line 19, Response ~line 32, From impl ~line 54) - Modify: crates/xylolabs-db/src/repo/inference_model.rs (create ~line 15, update ~line 84; new fn list_active_auto_queue after find_active_by_name_version) - Modify: crates/xylolabs-db/src/repo/inference_job.rs (list_by_facility ~line 56: add session_id_filter) - Modify: crates/xylolabs-server/src/routes/inference.rs (thread new args at create_model/update_model/list_jobs call sites; make is_valid_artifact_s3_key pub(crate))

Interfaces: - Consumes: nothing new. - Produces (used by Tasks 2-3): - InferenceModel.auto_queue: bool; InferenceModelResponse.auto_queue: bool - CreateInferenceModelRequest.auto_queue: Option<bool>, UpdateInferenceModelRequest.auto_queue: Option<bool> - repo::inference_model::create(pool, facility_id, name, version, framework, input_type, artifact_s3_key, config, auto_queue: bool) - repo::inference_model::update(pool, id, facility_id, artifact_s3_key, config, is_active, auto_queue: Option<bool>) - repo::inference_model::list_active_auto_queue(pool, facility_id) -> Result<Vec<InferenceModel>, sqlx::Error> - repo::inference_job::list_by_facility(pool, facility_id, page, per_page, status_filter, model_id_filter, session_id_filter: Option<Uuid>) - pub(crate) fn is_valid_artifact_s3_key(&str) -> bool (routes/inference.rs)

  • [ ] Step 1: Migration

Re-check newest prefix (ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort | tail -1), then create crates/xylolabs-db/migrations/20260725100000_inference_models_auto_queue.sql:

-- Inference admin (2026-07-25,
-- docs/superpowers/specs/2026-07-25-inference-admin-design.md).
--
-- auto_queue: per-model flag — when TRUE (and is_active), every ingest
-- session close in the model's facility enqueues one inference job
-- automatically (services/auto_inference.rs). Operator-set via the JWT
-- admin surface (SuperAdmin); default FALSE = manual queueing only.
ALTER TABLE inference_models
    ADD COLUMN auto_queue BOOLEAN NOT NULL DEFAULT false;

-- Dedup guard for the auto-queue hook: at most one live (queued/running)
-- job per (session, model). Completed/failed/cancelled jobs do not block a
-- manual re-submit. The hook treats a unique violation as already-queued.
CREATE UNIQUE INDEX idx_inference_jobs_session_model_live
    ON inference_jobs (session_id, model_id)
    WHERE status IN ('queued', 'running')
      AND session_id IS NOT NULL AND model_id IS NOT NULL;

Run: ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort -c — Expected: silent.

  • [ ] Step 2: Model + DTOs

crates/xylolabs-core/src/models/inference_model.rs — append to InferenceModel after is_active:

    /// Inference admin (2026-07-25): when true (and `is_active`), every
    /// ingest-session close in this facility auto-enqueues one job for this
    /// model at priority -10. SuperAdmin-set via the JWT admin surface.
    pub auto_queue: bool,

crates/xylolabs-core/src/dto/inference_model.rs: - CreateInferenceModelRequest: add pub auto_queue: Option<bool>, after config. - UpdateInferenceModelRequest: add pub auto_queue: Option<bool>, after is_active. - InferenceModelResponse: add pub auto_queue: bool, after is_active; in the From impl add auto_queue: m.auto_queue,.

  • [ ] Step 3: Repo threading

crates/xylolabs-db/src/repo/inference_model.rs:

a) create: add trailing param auto_queue: bool; SQL becomes:

        INSERT INTO inference_models (facility_id, name, version, framework, input_type, artifact_s3_key, config, auto_queue)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
        RETURNING *

with .bind(auto_queue) appended last.

b) update: add trailing param auto_queue: Option<bool>; SET gains auto_queue = COALESCE($6, auto_queue), (before updated_at) and .bind(auto_queue) appended last.

c) New fn after find_active_by_name_version:

/// Inference admin (2026-07-25): the auto-queue hook's model lookup. Small
/// result (expected 0-2 rows per facility), read on every session close.
pub async fn list_active_auto_queue(
    pool: &PgPool,
    facility_id: Uuid,
) -> Result<Vec<InferenceModel>, sqlx::Error> {
    sqlx::query_as::<_, InferenceModel>(
        "SELECT * FROM inference_models WHERE facility_id = $1 AND is_active = true AND auto_queue = true",
    )
    .bind(facility_id)
    .fetch_all(pool)
    .await
}

crates/xylolabs-db/src/repo/inference_job.rslist_by_facility: add trailing param session_id_filter: Option<Uuid>; both queries gain AND ($4::UUID IS NULL OR session_id = $4) (renumber LIMIT/OFFSET binds to $5/$6) with .bind(session_id_filter) inserted after the model bind in both.

  • [ ] Step 4: Internal route call sites + visibility

crates/xylolabs-server/src/routes/inference.rs: - create_model call: pass body.auto_queue.unwrap_or(false) as the new last arg. - update_model call: pass body.auto_queue as the new last arg. - list_jobs call: pass None for the new session_id_filter (internal query DTO unchanged — YAGNI). - Change fn is_valid_artifact_s3_key to pub(crate) fn is_valid_artifact_s3_key. - Let the compiler flag any other call sites (tests); fix by appending the neutral arg (false / None).

  • [ ] Step 5: Compile + lint (serially)

Run: CARGO_TARGET_DIR=/private/tmp/xls-sdd-target cargo check --tests then CARGO_TARGET_DIR=/private/tmp/xls-sdd-target cargo clippy --all-targets 2>&1 | tail -5 Expected: zero errors (pre-existing warnings in api_timeline_rollup_repo.rs are known).

  • [ ] Step 6: Commit + mine + push
git add crates/xylolabs-db/migrations/20260725100000_inference_models_auto_queue.sql \
  crates/xylolabs-core/src/models/inference_model.rs crates/xylolabs-core/src/dto/inference_model.rs \
  crates/xylolabs-db/src/repo/inference_model.rs crates/xylolabs-db/src/repo/inference_job.rs \
  crates/xylolabs-server/src/routes/inference.rs
git commit -S -m "feat(inference): ✨ auto_queue flag + live-job dedup index + repo threading"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 2: JWT admin routes (routes/inference_admin.rs) + router + integration tests

Files: - Create: crates/xylolabs-server/src/routes/inference_admin.rs - Modify: crates/xylolabs-server/src/routes/mod.rs (add pub mod inference_admin; alphabetically) - Modify: crates/xylolabs-server/src/router.rs (mount after the /inference-ops/* block, ~line 548) - Test: crates/xylolabs-server/tests/api_inference_admin.rs (new)

Interfaces: - Consumes: Task 1's repo signatures and pub(crate) is_valid_artifact_s3_key; existing require_role, require_facility_access, resolve_facility_filter, crate::routes::log_audit, repo::inference_job::{create, find_by_id, cancel, list_by_facility}, repo::ingest_session::find_by_id. - Produces (Task 4 consumes over HTTP): - GET /api/v1/inference-admin/models?facility_id=&page=&per_page=InferenceModelListResponse - POST /api/v1/inference-admin/models body {facility_id, name, version?, framework?, input_type?, artifact_s3_key, config?, auto_queue?} → 201 InferenceModelResponse - PATCH /api/v1/inference-admin/models/{id}?facility_id= body {artifact_s3_key?, config?, is_active?, auto_queue?}InferenceModelResponse - DELETE /api/v1/inference-admin/models/{id}?facility_id= → 204 (FK conflict → 409) - GET /api/v1/inference-admin/jobs?status=&model_id=&session_id=&page=&per_page=InferenceJobListResponse - POST /api/v1/inference-admin/jobs body {model_id, session_id} → 201 InferenceJobResponse - POST /api/v1/inference-admin/jobs/{id}/cancelInferenceJobResponse

  • [ ] Step 1: Write the failing integration tests

Create crates/xylolabs-server/tests/api_inference_admin.rs:

//! Inference admin (2026-07-25): JWT surface for the inference model
//! registry + job queue. Docker-gated like the other api_*.rs suites.

mod common;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use uuid::Uuid;

fn model_body(name: &str) -> serde_json::Value {
    serde_json::json!({
        "name": name,
        "artifact_s3_key": "google/gemma-4-12B-it",
        "framework": "custom",
        "input_type": "audio",
        "config": {"profile": "gemma-4-12b", "serving": "vllm"},
        "auto_queue": false
    })
}

/// SuperAdmin creates a model (with facility_id), toggles auto_queue via
/// PATCH, and the list surfaces it to a FacilityAdmin.
#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn model_crud_roundtrip_with_auto_queue() {
    let app = common::TestApp::setup().await;

    let mut body = model_body("gemma-4-12b");
    body["facility_id"] = serde_json::json!(app.facility_id);
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/inference-admin/models")
        .header("content-type", "application/json")
        .header("Authorization", app.auth_header()) // super admin
        .body(Body::from(body.to_string()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::CREATED, "create failed: {json}");
    assert_eq!(json["auto_queue"], false);
    assert_eq!(json["artifact_s3_key"], "google/gemma-4-12B-it");
    let model_id = json["id"].as_str().unwrap().to_string();

    let req = Request::builder()
        .method("PATCH")
        .uri(format!(
            "/api/v1/inference-admin/models/{model_id}?facility_id={}",
            app.facility_id
        ))
        .header("content-type", "application/json")
        .header("Authorization", app.auth_header())
        .body(Body::from(serde_json::json!({"auto_queue": true}).to_string()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "patch failed: {json}");
    assert_eq!(json["auto_queue"], true);

    // FacilityAdmin sees it in the facility-scoped list.
    let req = Request::builder()
        .uri("/api/v1/inference-admin/models")
        .header("Authorization", app.facility_auth_header())
        .body(Body::empty())
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "list failed: {json}");
    assert!(json["items"].as_array().unwrap().iter().any(|m| m["id"] == model_id.as_str()));
}

/// FacilityAdmin cannot create models (SuperAdmin-only) — 403.
#[tokio::test]
#[ignore]
async fn model_create_rejected_for_facility_admin() {
    let app = common::TestApp::setup().await;
    let mut body = model_body("forbidden-model");
    body["facility_id"] = serde_json::json!(app.facility_id);
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/inference-admin/models")
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(body.to_string()))
        .unwrap();
    let (status, _json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::FORBIDDEN);
}

/// FacilityAdmin queues a job for (model, session); the server builds
/// input_data = {"session_id": ...} itself. Cancel works.
#[tokio::test]
#[ignore]
async fn job_submit_builds_session_payload_and_cancels() {
    let app = common::TestApp::setup().await;

    // Seed a model (repo-direct; auto_queue=false).
    let model = xylolabs_db::repo::inference_model::create(
        &app.pool,
        app.facility_id,
        "gemma-4-12b",
        "1",
        xylolabs_core::models::inference_model::InferenceFramework::Custom,
        xylolabs_core::models::inference_model::InferenceInputType::Audio,
        "google/gemma-4-12B-it",
        serde_json::json!({}),
        false,
    )
    .await
    .unwrap();

    // Seed a device + closed session (same shape as api_ingest.rs
    // create_db_only_session).
    let device = xylolabs_db::repo::device::create(
        &app.pool, app.facility_id, 7_101, None, "ia-device", None, None, None, None,
    )
    .await
    .unwrap();
    let session = xylolabs_db::repo::ingest_session::create(
        &app.pool, app.facility_id, device.id, None, Some("ia-session"),
        xylolabs_core::models::IngestSessionMode::Continuous, None, None,
        serde_json::json!({}),
    )
    .await
    .unwrap();

    let body = serde_json::json!({"model_id": model.id, "session_id": session.id});
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/inference-admin/jobs")
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(body.to_string()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::CREATED, "submit failed: {json}");
    assert_eq!(json["input_data"]["session_id"], session.id.to_string());
    assert_eq!(json["status"], "queued");
    let job_id = json["id"].as_str().unwrap().to_string();

    let req = Request::builder()
        .method("POST")
        .uri(format!("/api/v1/inference-admin/jobs/{job_id}/cancel"))
        .header("Authorization", app.facility_auth_header())
        .body(Body::empty())
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "cancel failed: {json}");
    assert_eq!(json["status"], "cancelled");
}

/// A session id from another facility is rejected with 403/400, never queued.
#[tokio::test]
#[ignore]
async fn job_submit_rejects_cross_facility_session() {
    let app = common::TestApp::setup().await;
    let model = xylolabs_db::repo::inference_model::create(
        &app.pool, app.facility_id, "xmodel", "1",
        xylolabs_core::models::inference_model::InferenceFramework::Custom,
        xylolabs_core::models::inference_model::InferenceInputType::Audio,
        "google/gemma-4-12B-it", serde_json::json!({}), false,
    )
    .await
    .unwrap();

    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/inference-admin/jobs")
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(
            serde_json::json!({"model_id": model.id, "session_id": Uuid::new_v4()}).to_string(),
        ))
        .unwrap();
    let (status, _json) = app.request_json(req).await;
    assert!(
        status == StatusCode::BAD_REQUEST || status == StatusCode::NOT_FOUND,
        "cross-facility/unknown session must be rejected, got {status}"
    );
}
  • [ ] Step 2: Verify tests fail to route (compile gate)

Run: CARGO_TARGET_DIR=/private/tmp/xls-sdd-target cargo check --tests 2>&1 | tail -5 Expected: compiles (tests are HTTP-level). If the test DB is up, cargo test -p xylolabs-server --test api_inference_admin -- --ignored — Expected: 404s (routes absent) → FAIL.

  • [ ] Step 3: Implement routes/inference_admin.rs
//! Inference admin (2026-07-25): JWT/RBAC surface for the inference model
//! registry and job queue, so operators can register LLM presets and queue
//! per-model analysis jobs from the admin console. Reuses the internal
//! routes' repo layer; RBAC follows /inference-ops/* (403 on role/facility
//! failures — no tenancy-hiding 404s on this admin surface).
//!
//! Model CRUD + auto_queue: SuperAdmin. Job list/submit/cancel: FacilityAdmin.

use axum::extract::{ConnectInfo, Path, Query, State};
use axum::{Extension, Json};
use axum::http::{HeaderMap, StatusCode};
use std::net::SocketAddr;
use uuid::Uuid;

use xylolabs_core::dto::inference_model::{
    InferenceModelListResponse, InferenceModelResponse, UpdateInferenceModelRequest,
};
use xylolabs_core::dto::inference_job::{InferenceJobListResponse, InferenceJobResponse};
use xylolabs_core::models::inference_model::{InferenceFramework, InferenceInputType};
use xylolabs_core::models::inference_job::InferenceJobStatus;
use xylolabs_db::repo;

use crate::error::AppError;
use crate::extractors::authenticated::AuthenticatedUser;
use crate::middleware::rbac::{require_facility_access, require_role, Role};
use crate::routes::resolve_facility_filter;
use crate::state::AppState;

/// Body for POST /inference-admin/models — the internal Create DTO plus the
/// target facility (SuperAdmin registers models into a chosen facility).
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminCreateModelRequest {
    pub facility_id: Uuid,
    pub name: String,
    pub version: Option<String>,
    pub framework: Option<InferenceFramework>,
    pub input_type: Option<InferenceInputType>,
    pub artifact_s3_key: String,
    pub config: Option<serde_json::Value>,
    pub auto_queue: Option<bool>,
}

#[derive(Debug, Default, serde::Deserialize)]
pub struct AdminModelQuery {
    pub facility_id: Option<Uuid>,
    pub page: Option<i32>,
    pub per_page: Option<i32>,
}

#[derive(Debug, Default, serde::Deserialize)]
pub struct AdminJobListQuery {
    pub status: Option<InferenceJobStatus>,
    pub model_id: Option<Uuid>,
    pub session_id: Option<Uuid>,
    pub page: Option<i32>,
    pub per_page: Option<i32>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminSubmitJobRequest {
    pub model_id: Uuid,
    pub session_id: Uuid,
}

/// Resolve the facility this admin call operates on: facility-bound users
/// get their own facility (a mismatching explicit param is a 403);
/// SuperAdmin must name one via the query/body param.
fn resolve_admin_facility(
    user: &AuthenticatedUser,
    explicit: Option<Uuid>,
) -> Result<Uuid, AppError> {
    match (user.facility_id, explicit) {
        (Some(own), None) => Ok(own),
        (Some(own), Some(req)) => {
            if own != req {
                return Err(AppError::Forbidden(
                    "cannot operate on another facility".to_string(),
                ));
            }
            Ok(own)
        }
        (None, Some(req)) => Ok(req),
        (None, None) => Err(AppError::BadRequest(
            "facility_id is required for cross-facility admins".to_string(),
        )),
    }
}

const DEFAULT_PER_PAGE: i32 = 50;
const MAX_PER_PAGE: i32 = 200;

fn page_params(page: Option<i32>, per_page: Option<i32>) -> (i32, i32) {
    (
        page.unwrap_or(1).max(1),
        per_page.unwrap_or(DEFAULT_PER_PAGE).clamp(1, MAX_PER_PAGE),
    )
}

/// GET /api/v1/inference-admin/models — FacilityAdmin.
pub async fn list_models(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    Query(q): Query<AdminModelQuery>,
) -> Result<Json<InferenceModelListResponse>, AppError> {
    require_role(&user, Role::FacilityAdmin)?;
    let facility_id = resolve_admin_facility(&user, q.facility_id)?;
    let (page, per_page) = page_params(q.page, q.per_page);
    let (items, total) =
        repo::inference_model::list_by_facility(&state.db, facility_id, page, per_page).await?;
    Ok(Json(InferenceModelListResponse {
        items: items.into_iter().map(InferenceModelResponse::from).collect(),
        total,
        page,
        per_page,
    }))
}

/// POST /api/v1/inference-admin/models — SuperAdmin.
pub async fn create_model(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<AdminCreateModelRequest>,
) -> Result<(StatusCode, Json<InferenceModelResponse>), AppError> {
    require_role(&user, Role::SuperAdmin)?;
    if body.name.trim().is_empty() || body.name.len() > 255 {
        return Err(AppError::BadRequest("name must be 1-255 bytes".to_string()));
    }
    if !crate::routes::inference::is_valid_artifact_s3_key(&body.artifact_s3_key) {
        return Err(AppError::BadRequest(
            "artifact_s3_key must be 1-1024 chars matching [A-Za-z0-9._/-]+ and must not contain '..'".to_string(),
        ));
    }
    let config = body.config.unwrap_or_else(|| serde_json::json!({}));
    if serde_json::to_vec(&config).map(|v| v.len()).unwrap_or(usize::MAX) > 64 * 1024 {
        return Err(AppError::BadRequest("config must be at most 64 KiB".to_string()));
    }
    let model = repo::inference_model::create(
        &state.db,
        body.facility_id,
        body.name.trim(),
        body.version.as_deref().unwrap_or("1"),
        body.framework.unwrap_or(InferenceFramework::Custom),
        body.input_type.unwrap_or(InferenceInputType::Audio),
        &body.artifact_s3_key,
        config,
        body.auto_queue.unwrap_or(false),
    )
    .await
    .map_err(|e| {
        if crate::error::is_unique_violation(&e) {
            AppError::Conflict("a model with this (name, version) already exists in the facility".to_string())
        } else if crate::error::is_fk_violation(&e) {
            AppError::BadRequest("target facility does not exist".to_string())
        } else {
            AppError::from(e)
        }
    })?;
    let audit_ip = crate::router::resolve_audit_ip(&headers, connect_info.ip(), &state.config.trusted_proxy_ips);
    crate::routes::log_audit(
        state.db.clone(), Some(user.user_id), Some(model.facility_id),
        "inference_model.created", "inference_model", Some(model.id),
        Some(serde_json::json!({"name": model.name, "auto_queue": model.auto_queue})),
        Some(audit_ip.to_string()),
    );
    Ok((StatusCode::CREATED, Json(model.into())))
}

/// PATCH /api/v1/inference-admin/models/{id} — SuperAdmin.
pub async fn update_model(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Path(id): Path<Uuid>,
    Query(q): Query<AdminModelQuery>,
    Json(body): Json<UpdateInferenceModelRequest>,
) -> Result<Json<InferenceModelResponse>, AppError> {
    require_role(&user, Role::SuperAdmin)?;
    let facility_id = q.facility_id.ok_or_else(|| {
        AppError::BadRequest("facility_id query parameter is required".to_string())
    })?;
    if let Some(ref key) = body.artifact_s3_key
        && !crate::routes::inference::is_valid_artifact_s3_key(key)
    {
        return Err(AppError::BadRequest(
            "artifact_s3_key must be 1-1024 chars matching [A-Za-z0-9._/-]+ and must not contain '..'".to_string(),
        ));
    }
    let model = repo::inference_model::update(
        &state.db, id, facility_id,
        body.artifact_s3_key.as_deref(), body.config, body.is_active, body.auto_queue,
    )
    .await?
    .ok_or_else(|| AppError::NotFound(format!("model {id} not found")))?;
    let audit_ip = crate::router::resolve_audit_ip(&headers, connect_info.ip(), &state.config.trusted_proxy_ips);
    crate::routes::log_audit(
        state.db.clone(), Some(user.user_id), Some(model.facility_id),
        "inference_model.updated", "inference_model", Some(model.id),
        Some(serde_json::json!({"auto_queue": model.auto_queue, "is_active": model.is_active})),
        Some(audit_ip.to_string()),
    );
    Ok(Json(model.into()))
}

/// DELETE /api/v1/inference-admin/models/{id} — SuperAdmin. FK conflict → 409.
pub async fn delete_model(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Path(id): Path<Uuid>,
    Query(q): Query<AdminModelQuery>,
) -> Result<StatusCode, AppError> {
    require_role(&user, Role::SuperAdmin)?;
    let facility_id = q.facility_id.ok_or_else(|| {
        AppError::BadRequest("facility_id query parameter is required".to_string())
    })?;
    repo::inference_model::delete(&state.db, id, facility_id)
        .await
        .map_err(|e| {
            if crate::error::is_fk_violation(&e) {
                AppError::Conflict("model has jobs referencing it; deactivate instead".to_string())
            } else if crate::error::is_row_not_found(&e) {
                AppError::NotFound(format!("model {id} not found"))
            } else {
                AppError::from(e)
            }
        })?;
    let audit_ip = crate::router::resolve_audit_ip(&headers, connect_info.ip(), &state.config.trusted_proxy_ips);
    crate::routes::log_audit(
        state.db.clone(), Some(user.user_id), Some(facility_id),
        "inference_model.deleted", "inference_model", Some(id), None,
        Some(audit_ip.to_string()),
    );
    Ok(StatusCode::NO_CONTENT)
}

/// GET /api/v1/inference-admin/jobs — FacilityAdmin.
pub async fn list_jobs(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    Query(q): Query<AdminJobListQuery>,
) -> Result<Json<InferenceJobListResponse>, AppError> {
    require_role(&user, Role::FacilityAdmin)?;
    let facility_id = resolve_facility_filter(&user, &state.db)
        .await?
        .ok_or_else(|| AppError::BadRequest("facility scope required".to_string()))?;
    let (page, per_page) = page_params(q.page, q.per_page);
    let (items, total) = repo::inference_job::list_by_facility(
        &state.db, facility_id, page, per_page, q.status, q.model_id, q.session_id,
    )
    .await?;
    Ok(Json(InferenceJobListResponse {
        items: items.into_iter().map(InferenceJobResponse::from).collect(),
        total,
        page,
        per_page,
    }))
}

/// POST /api/v1/inference-admin/jobs — FacilityAdmin. The server builds the
/// ai-client payload itself: input_data = {"session_id": ...}.
pub async fn submit_job(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<AdminSubmitJobRequest>,
) -> Result<(StatusCode, Json<InferenceJobResponse>), AppError> {
    require_role(&user, Role::FacilityAdmin)?;
    let facility_id = resolve_facility_filter(&user, &state.db)
        .await?
        .ok_or_else(|| AppError::BadRequest("facility scope required".to_string()))?;
    require_facility_access(&user, facility_id)?;

    let model = repo::inference_model::find_by_id(&state.db, body.model_id, facility_id)
        .await?
        .ok_or_else(|| AppError::BadRequest("model does not belong to this facility".to_string()))?;
    if !model.is_active {
        return Err(AppError::BadRequest(format!("model {} is not active", model.id)));
    }
    let session = repo::ingest_session::find_by_id(&state.db, body.session_id)
        .await?
        .filter(|s| s.facility_id == facility_id)
        .ok_or_else(|| AppError::BadRequest("session does not belong to this facility".to_string()))?;

    let job = repo::inference_job::create(
        &state.db, facility_id, model.id, None, Some(session.device_id), Some(session.id), None,
        model.input_type, serde_json::json!({"session_id": session.id}),
        0, // manual priority: outranks auto jobs (-10)
        3,
    )
    .await
    .map_err(|e| {
        if crate::error::is_unique_violation(&e) {
            AppError::Conflict("a live job for this (session, model) already exists".to_string())
        } else {
            AppError::from(e)
        }
    })?;
    let audit_ip = crate::router::resolve_audit_ip(&headers, connect_info.ip(), &state.config.trusted_proxy_ips);
    crate::routes::log_audit(
        state.db.clone(), Some(user.user_id), Some(facility_id),
        "inference_job.submitted", "inference_job", Some(job.id),
        Some(serde_json::json!({"model_id": model.id, "session_id": session.id})),
        Some(audit_ip.to_string()),
    );
    Ok((StatusCode::CREATED, Json(job.into())))
}

/// POST /api/v1/inference-admin/jobs/{id}/cancel — FacilityAdmin.
pub async fn cancel_job(
    State(state): State<AppState>,
    Extension(user): Extension<AuthenticatedUser>,
    ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Path(id): Path<Uuid>,
) -> Result<Json<InferenceJobResponse>, AppError> {
    require_role(&user, Role::FacilityAdmin)?;
    let facility_id = resolve_facility_filter(&user, &state.db)
        .await?
        .ok_or_else(|| AppError::BadRequest("facility scope required".to_string()))?;
    let job = repo::inference_job::cancel(&state.db, id, facility_id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("job {id} not found or not cancellable")))?;
    let audit_ip = crate::router::resolve_audit_ip(&headers, connect_info.ip(), &state.config.trusted_proxy_ips);
    crate::routes::log_audit(
        state.db.clone(), Some(user.user_id), Some(facility_id),
        "inference_job.cancelled", "inference_job", Some(job.id), None,
        Some(audit_ip.to_string()),
    );
    Ok(Json(job.into()))
}

Adaptation notes for the implementer (the plan's code is the target; reconcile with reality): - Check repo::inference_job::cancel's actual signature/return (~line 276) and repo::ingest_session::find_by_id's return type; adjust the two call sites to match (keep semantics: cancellable-only, facility-checked). - Check AuthenticatedUser's facility field name and resolve_facility_filter behavior in routes/devices.rs for the exact idiom; resolve_admin_facility is used by model handlers, resolve_facility_filter by job handlers — if resolve_facility_filter returns Option<Uuid> with None meaning SuperAdmin-unscoped, require an explicit facility for SuperAdmin job listing via q.facility_id-style param following the models pattern instead. Keep RBAC semantics from the spec. - InferenceJobListResponse/InferenceJobResponse exist in dto/inference_job.rs (used by internal routes) — reuse, do not redefine.

  • [ ] Step 4: Mount routes

crates/xylolabs-server/src/routes/mod.rs: add pub mod inference_admin; (alphabetical).

crates/xylolabs-server/src/router.rs — directly after the /inference-ops/rubric/preview route (~line 548), same JWT layer:

        .route(
            "/inference-admin/models",
            get(inference_admin::list_models).post(inference_admin::create_model),
        )
        .route(
            "/inference-admin/models/{id}",
            patch(inference_admin::update_model).delete(inference_admin::delete_model),
        )
        .route(
            "/inference-admin/jobs",
            get(inference_admin::list_jobs).post(inference_admin::submit_job),
        )
        .route(
            "/inference-admin/jobs/{id}/cancel",
            post(inference_admin::cancel_job),
        )

Add inference_admin to the use crate::routes::{...} import list (~line 34). Check the RBAC coverage tripwire (tests/rbac_coverage.rs) — if it enumerates JWT routes, add the four new paths per its convention.

  • [ ] Step 5: Compile, lint, run tests

Run serially: CARGO_TARGET_DIR=/private/tmp/xls-sdd-target cargo check --tests, then clippy, then (if DB up) cargo test -p xylolabs-server --test api_inference_admin -- --ignored --nocapture. Expected: 4/4 pass.

  • [ ] Step 6: Commit + mine + push
git add crates/xylolabs-server/src/routes/inference_admin.rs crates/xylolabs-server/src/routes/mod.rs \
  crates/xylolabs-server/src/router.rs crates/xylolabs-server/tests/api_inference_admin.rs
git commit -S -m "feat(inference): ✨ JWT admin surface for model registry + job queue"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 3: Auto-queue hook (services/auto_inference.rs + manager wiring)

Files: - Create: crates/xylolabs-server/src/services/auto_inference.rs - Modify: crates/xylolabs-server/src/services/mod.rs (add pub mod auto_inference;) - Modify: crates/xylolabs-server/src/ingest/manager.rs (after the successful repo::ingest_session::close at ~line 2930) - Test: crates/xylolabs-server/tests/api_inference_admin.rs (append)

Interfaces: - Consumes: repo::inference_model::list_active_auto_queue (Task 1), repo::inference_job::create (existing), the manager's db, facility_id, device_id, session_id in scope at the close site. - Produces: services::auto_inference::enqueue_auto_jobs(db: &PgPool, facility_id: Uuid, device_id: Uuid, session_id: Uuid) (returns nothing; logs internally).

  • [ ] Step 1: Write the failing test (append to api_inference_admin.rs)
/// Auto-queue: closing a session in a facility with an active auto_queue
/// model creates exactly one job for it (priority -10); a duplicate enqueue
/// attempt is absorbed by the live-job unique index.
#[tokio::test]
#[ignore]
async fn session_close_auto_queues_one_job() {
    let app = common::TestApp::setup().await;

    let model = xylolabs_db::repo::inference_model::create(
        &app.pool, app.facility_id, "auto-model", "1",
        xylolabs_core::models::inference_model::InferenceFramework::Custom,
        xylolabs_core::models::inference_model::InferenceInputType::Audio,
        "google/gemma-4-12B-it", serde_json::json!({}), true, // auto_queue = true
    )
    .await
    .unwrap();

    // Open a real session through the ingest API (device_uid auto-registers),
    // then close it through the API so the manager's close path runs the hook.
    let body = serde_json::json!({
        "device_uid": 7_202, "name": "auto-q session",
        "streams": [{"stream_index": 0, "name": "temperature", "value_type": "f64", "sample_rate_hz": 10.0}]
    });
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/ingest/sessions")
        .header("content-type", "application/json")
        .header("X-Api-Key", &app.api_key_raw)
        .body(Body::from(body.to_string()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::CREATED, "session open failed: {json}");
    let session_id = json["id"].as_str().unwrap().to_string();

    let req = Request::builder()
        .method("POST")
        .uri(format!("/api/v1/ingest/sessions/{session_id}/close"))
        .header("X-Api-Key", &app.api_key_raw)
        .body(Body::empty())
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "close failed: {json}");

    // Exactly one queued job for (session, model), priority -10.
    let rows: Vec<(Uuid, i32, String)> = sqlx::query_as(
        "SELECT model_id, priority, status FROM inference_jobs WHERE session_id = $1",
    )
    .bind(Uuid::parse_str(&session_id).unwrap())
    .fetch_all(&app.pool)
    .await
    .unwrap();
    assert_eq!(rows.len(), 1, "exactly one auto job expected: {rows:?}");
    assert_eq!(rows[0].0, model.id);
    assert_eq!(rows[0].1, -10);
    assert_eq!(rows[0].2, "queued");

    // Duplicate enqueue attempt (direct helper call) is absorbed.
    xylolabs_server::services::auto_inference::enqueue_auto_jobs(
        &app.pool, app.facility_id,
        rows_device_id(&app, &session_id).await,
        Uuid::parse_str(&session_id).unwrap(),
    )
    .await;
    let n: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM inference_jobs WHERE session_id = $1",
    )
    .bind(Uuid::parse_str(&session_id).unwrap())
    .fetch_one(&app.pool)
    .await
    .unwrap();
    assert_eq!(n, 1, "dedup index must absorb the duplicate");
}

async fn rows_device_id(app: &common::TestApp, session_id: &str) -> Uuid {
    sqlx::query_scalar("SELECT device_id FROM ingest_sessions WHERE id = $1")
        .bind(Uuid::parse_str(session_id).unwrap())
        .fetch_one(&app.pool)
        .await
        .unwrap()
}

(If xylolabs_server::services::auto_inference is not visible from integration tests, mark the module pub in services/mod.rs — the sibling service modules already are.)

  • [ ] Step 2: Run to verify failure (if DB up)

Run: cargo test -p xylolabs-server --test api_inference_admin session_close_auto_queues_one_job -- --ignored Expected: FAIL (0 jobs found).

  • [ ] Step 3: Implement services/auto_inference.rs
//! Inference admin (2026-07-25): auto-queue hook. When an ingest session
//! transitions to `closed`, enqueue one inference job per active
//! `auto_queue` model in the session's facility.
//!
//! Called from the single `ingest_session::close` success path in
//! `ingest/manager.rs`. Strictly best-effort: every failure is a warn and
//! never propagates — the close path must not be blocked by inference
//! bookkeeping. NOT called from the startup orphan reconcile
//! (`reconcile_orphaned_sessions_on_startup`): those are stale crash
//! leftovers, and mass-enqueueing analysis for hours-old sessions on boot
//! would be noise (the ai-client's own freshness policy would skip them
//! anyway).

use sqlx::PgPool;
use uuid::Uuid;
use xylolabs_db::repo;

/// Auto jobs sort below every manual job (manual default priority = 0).
const AUTO_JOB_PRIORITY: i32 = -10;
const AUTO_JOB_MAX_ATTEMPTS: i32 = 3;

pub async fn enqueue_auto_jobs(
    db: &PgPool,
    facility_id: Uuid,
    device_id: Uuid,
    session_id: Uuid,
) {
    let models = match repo::inference_model::list_active_auto_queue(db, facility_id).await {
        Ok(m) => m,
        Err(e) => {
            tracing::warn!(%facility_id, %session_id, error = %e,
                "auto-queue: model lookup failed; skipping");
            return;
        }
    };
    for model in models {
        match repo::inference_job::create(
            db,
            facility_id,
            model.id,
            None,
            Some(device_id),
            Some(session_id),
            None,
            model.input_type,
            serde_json::json!({"session_id": session_id}),
            AUTO_JOB_PRIORITY,
            AUTO_JOB_MAX_ATTEMPTS,
        )
        .await
        {
            Ok(job) => {
                tracing::debug!(%session_id, model_id = %model.id, job_id = %job.id,
                    "auto-queued inference job on session close");
            }
            Err(e) if xylolabs_db::is_unique_violation_db(&e) => {
                // Live job already exists for (session, model) — the partial
                // unique index (idx_inference_jobs_session_model_live) absorbed
                // the duplicate. Expected on close retries; not an error.
            }
            Err(e) => {
                tracing::warn!(%session_id, model_id = %model.id, error = %e,
                    "auto-queue: job insert failed; continuing");
            }
        }
    }
}

Adaptation note: locate the crate's existing unique-violation helper — the server crate has crate::error::is_unique_violation(&e) taking sqlx::Error; if it lives in the server crate, use that (crate::error::is_unique_violation) since this module is in xylolabs-server. Do not add a new helper if one exists.

  • [ ] Step 4: Wire into the manager close path

crates/xylolabs-server/src/ingest/manager.rs — the close-success block (~line 2930). The facility_id/device_id for the session are available in the close fn's scope (the manager resolves them; if only session_id is in scope at that point, fetch them from the SessionState/SessionActivity entry BEFORE it is removed from the map). Insert AFTER repo::ingest_session::close succeeds and after the maps are cleaned up:

        // Inference admin (2026-07-25): fire the auto-queue hook now that the
        // session row is durably closed. Best-effort by contract — the helper
        // swallows and warns on every failure; the close result is already
        // committed and must not be affected.
        crate::services::auto_inference::enqueue_auto_jobs(
            &self.db, facility_id, device_id, session_id,
        )
        .await;

services/mod.rs: add pub mod auto_inference; (alphabetical).

  • [ ] Step 5: Compile, lint, run tests

Serially: cargo check --tests, clippy, then (DB up) the new test + re-run the full api_inference_admin and api_ingest suites (close-path regression). Expected: all pass; api_ingest unaffected.

  • [ ] Step 6: Commit + mine + push
git add crates/xylolabs-server/src/services/auto_inference.rs crates/xylolabs-server/src/services/mod.rs \
  crates/xylolabs-server/src/ingest/manager.rs crates/xylolabs-server/tests/api_inference_admin.rs
git commit -S -m "feat(inference): ✨ auto-queue jobs on session close for auto_queue models"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 4: Admin UI (client + InferencePage + session-detail action + i18n)

Files: - Create: frontend/src/api/inferenceAdmin.ts - Create: frontend/src/pages/InferencePage.tsx - Modify: frontend/src/App.tsx (lazy import + route, pattern at ~line 41/202) - Modify: frontend/src/components/layout/Sidebar.tsx (nav entry beside the inference-ops one) - Modify: frontend/src/pages/MetadataSessionDetailPage.tsx (request-analysis action) - Modify: frontend/src/i18n/index.ts (en + ko blocks)

Interfaces: - Consumes: Task 2's HTTP endpoints exactly as specified in its Produces block. - Produces: nothing consumed later.

  • [ ] Step 1: API client frontend/src/api/inferenceAdmin.ts
import { api } from './client'

export interface InferenceModel {
  id: string
  facility_id: string
  name: string
  version: string
  framework: 'onnx' | 'tensorrt' | 'pytorch' | 'custom'
  input_type: 'audio' | 'sensor_fusion' | 'image' | 'text'
  artifact_s3_key: string
  config: Record<string, unknown>
  is_active: boolean
  auto_queue: boolean
  created_at: string
  updated_at: string
}

export interface InferenceJob {
  id: string
  facility_id: string
  model_id: string | null
  session_id: string | null
  device_id: string | null
  status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
  priority: number
  attempts: number
  max_attempts: number
  error_message: string | null
  result: Record<string, unknown> | null
  queued_at: string
  started_at: string | null
  completed_at: string | null
}

export interface Paged<T> {
  items: T[]
  total: number
  page: number
  per_page: number
}

export interface CreateModelBody {
  facility_id: string
  name: string
  version?: string
  framework?: InferenceModel['framework']
  input_type?: InferenceModel['input_type']
  artifact_s3_key: string
  config?: Record<string, unknown>
  auto_queue?: boolean
}

export interface UpdateModelBody {
  artifact_s3_key?: string
  config?: Record<string, unknown>
  is_active?: boolean
  auto_queue?: boolean
}

export function listModels(facilityId?: string): Promise<Paged<InferenceModel>> {
  const qs = facilityId ? `?facility_id=${encodeURIComponent(facilityId)}` : ''
  return api.get<Paged<InferenceModel>>(`/v1/inference-admin/models${qs}`)
}

export function createModel(body: CreateModelBody): Promise<InferenceModel> {
  return api.post<InferenceModel>('/v1/inference-admin/models', body)
}

export function updateModel(id: string, facilityId: string, body: UpdateModelBody): Promise<InferenceModel> {
  return api.patch<InferenceModel>(
    `/v1/inference-admin/models/${id}?facility_id=${encodeURIComponent(facilityId)}`,
    body,
  )
}

export function deleteModel(id: string, facilityId: string): Promise<void> {
  return api.delete<void>(`/v1/inference-admin/models/${id}?facility_id=${encodeURIComponent(facilityId)}`)
}

export interface ListJobsParams {
  status?: InferenceJob['status']
  model_id?: string
  session_id?: string
  page?: number
  per_page?: number
}

export function listJobs(params: ListJobsParams = {}): Promise<Paged<InferenceJob>> {
  const q = new URLSearchParams()
  if (params.status) q.set('status', params.status)
  if (params.model_id) q.set('model_id', params.model_id)
  if (params.session_id) q.set('session_id', params.session_id)
  if (params.page != null) q.set('page', String(params.page))
  if (params.per_page != null) q.set('per_page', String(params.per_page))
  const qs = q.toString()
  return api.get<Paged<InferenceJob>>(`/v1/inference-admin/jobs${qs ? `?${qs}` : ''}`)
}

export function submitJob(modelId: string, sessionId: string): Promise<InferenceJob> {
  return api.post<InferenceJob>('/v1/inference-admin/jobs', { model_id: modelId, session_id: sessionId })
}

export function cancelJob(id: string): Promise<InferenceJob> {
  return api.post<InferenceJob>(`/v1/inference-admin/jobs/${id}/cancel`, {})
}

Adaptation: match api.get/post/patch/delete helper names to frontend/src/api/client.ts (verify against devices.ts usage) and the exact InferenceJob field names to the backend InferenceJobResponse (read crates/xylolabs-core/src/dto/inference_job.rs; adjust the interface, never invent fields).

  • [ ] Step 2: InferencePage.tsx

Follow FirmwarePage.tsx structure (read it first; reuse its table/modal/toast idioms and i18n usage). Required behavior — implement with the page's existing component vocabulary (ConfirmDialog, Toast, Modal, react-query):

  • Models section: table (name/version, input_type, artifact key, active badge, auto badge, updated_at). SuperAdmin (user.role, reuse the role helper the sidebar/pages use): "모델 등록" button opening a modal form (name, version, artifact_s3_key, framework select, input_type select, auto_queue checkbox, config JSON textarea validated with JSON.parse on submit); per-row edit modal (artifact/config/is_active/auto_queue) and delete with ConfirmDialog (409 shows a toast suggesting deactivate). FacilityAdmin: read-only table.
  • Jobs section: table (queued_at, model name via models lookup, session link /sessions/{session_id}, status chip, attempts n/max, error_message truncated, cancel button when status queued/running with ConfirmDialog). useQuery with refetchInterval: 10_000. Status filter select.
  • All strings via t('inference.*'); SVG icons only; responsive table wrapper as other pages (ScrollFadeContainer or the page-local equivalent).

  • [ ] Step 3: Route + sidebar

App.tsx (mirror FirmwarePage at ~41 and ~202):

const InferencePage = lazyRetry(() => import('./pages/InferencePage'), 'inference')
// ... in the router children, same guard wrapper as FirmwarePage:
<Route path="/inference" element={<InferencePage />} />

Sidebar.tsx: add an entry { to: '/inference', label: t('nav.inference') } beside the inference-ops entry, with an inline SVG icon (copy the chip/cpu icon shape used by similar entries; any Heroicons 20/solid path is fine).

Adaptation: match the actual route-guard wrapper (role gate) used by FirmwarePage's route — jobs are FacilityAdmin, so use the same RoleGate/props as the inference-ops page.

  • [ ] Step 4: Session-detail action

MetadataSessionDetailPage.tsx: add a "분석 요청" (t('inference.requestAnalysis')) button in the header/actions area. On click open a small modal listing active models (listModels() filtered is_active) as a select; submit → submitJob(modelId, sessionId); success toast t('inference.queuedToast') with a link to /inference; 409 conflict toast t('inference.alreadyQueued'). Hide the button for roles below FacilityAdmin (same role helper as other admin actions on that page).

  • [ ] Step 5: i18n (both blocks)

frontend/src/i18n/index.ts — en block:

    'nav.inference': 'Inference',
    'inference.title': 'Inference',
    'inference.models': 'Models',
    'inference.jobs': 'Jobs',
    'inference.registerModel': 'Register model',
    'inference.editModel': 'Edit model',
    'inference.deleteModel': 'Delete model',
    'inference.deleteModelConfirm': 'Delete model {name}? Jobs referencing it keep their history.',
    'inference.deleteConflict': 'Model has jobs referencing it — deactivate it instead.',
    'inference.autoQueue': 'Auto-queue',
    'inference.autoQueueHint': 'When on, every session close queues one job for this model.',
    'inference.active': 'Active',
    'inference.artifact': 'Artifact / checkpoint',
    'inference.configJson': 'Config (JSON)',
    'inference.configInvalid': 'Config must be valid JSON.',
    'inference.status': 'Status',
    'inference.attempts': 'Attempts',
    'inference.cancelJob': 'Cancel job',
    'inference.cancelJobConfirm': 'Cancel this job?',
    'inference.requestAnalysis': 'Request analysis',
    'inference.selectModel': 'Select a model',
    'inference.queuedToast': 'Analysis job queued.',
    'inference.alreadyQueued': 'A live job for this session and model already exists.',
    'inference.noModels': 'No models registered.',
    'inference.noJobs': 'No jobs yet.',

ko block (mirror keys):

    'nav.inference': '추론',
    'inference.title': '추론',
    'inference.models': '모델',
    'inference.jobs': '잡',
    'inference.registerModel': '모델 등록',
    'inference.editModel': '모델 수정',
    'inference.deleteModel': '모델 삭제',
    'inference.deleteModelConfirm': '{name} 모델을 삭제할까요? 이 모델을 참조하는 잡 이력은 유지됩니다.',
    'inference.deleteConflict': '이 모델을 참조하는 잡이 있어 삭제할 수 없습니다. 비활성화를 사용하세요.',
    'inference.autoQueue': '자동 큐잉',
    'inference.autoQueueHint': '켜면 세션이 닫힐 때마다 이 모델로 잡이 하나씩 생성됩니다.',
    'inference.active': '활성',
    'inference.artifact': '아티팩트/체크포인트',
    'inference.configJson': '설정 (JSON)',
    'inference.configInvalid': '설정은 유효한 JSON이어야 합니다.',
    'inference.status': '상태',
    'inference.attempts': '시도',
    'inference.cancelJob': '잡 취소',
    'inference.cancelJobConfirm': '이 잡을 취소할까요?',
    'inference.requestAnalysis': '분석 요청',
    'inference.selectModel': '모델 선택',
    'inference.queuedToast': '분석 잡을 큐에 넣었습니다.',
    'inference.alreadyQueued': '이 세션과 모델의 진행 중인 잡이 이미 있습니다.',
    'inference.noModels': '등록된 모델이 없습니다.',
    'inference.noJobs': '잡이 아직 없습니다.',
  • [ ] Step 6: Type-check + build both frontends + keyParity

Run: cd frontend && npx tsc -b --noEmit && npx vite build && npx vitest run src/i18n/keyParity.test.ts, then cd ../frontend-app && npx tsc -b --noEmit && npx vite build. Expected: zero errors; keyParity PASS. (Playwright 3-viewport pass runs post-deploy in Task 5.)

  • [ ] Step 7: Commit + mine + push
git add frontend/src/api/inferenceAdmin.ts frontend/src/pages/InferencePage.tsx \
  frontend/src/App.tsx frontend/src/components/layout/Sidebar.tsx \
  frontend/src/pages/MetadataSessionDetailPage.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(admin): ✨ inference page (models + jobs) and session analysis action"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 5: Docs + deploy + post-deploy verification

Files: - Modify: docs/API.en.md, docs/API.ko.md (new "Inference Admin" subsection in the inference section — locate with grep -n "inference" docs/API.en.md | head)

Interfaces: none (documentation + ops).

  • [ ] Step 1: Docs (EN, then KO mirroring the section)

Document, matching the surrounding endpoint-doc style: the seven endpoints with methods/paths/roles, the POST /jobs body {model_id, session_id} and the server-built input_data = {"session_id": …} contract, auto_queue semantics (one auto job per session close, priority -10, live-job dedup per (session, model), best-effort — close never fails on queue errors, startup orphan reconcile excluded), and the 409 semantics for model delete and duplicate live jobs. KO: same content in Korean following this repo's established prose rules (하다체, no em dash/가운뎃점/tilde ranges).

  • [ ] Step 2: Commit docs + mine + push
git add docs/API.en.md docs/API.ko.md
git commit -S -m "docs(api): 📝 document inference-admin endpoints + auto_queue"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push
  • [ ] Step 3: Deploy + verify
bash scripts/deploy.sh

Then: 1. Container Up (healthy); api.xylolabs.com, admin.api.xylolabs.com, docs.api.xylolabs.com respond; bundle hash changed; footer build stamp matches rev. 2. Migration applied (no crash-loop; \d inference_models shows auto_queue, index idx_inference_jobs_session_model_live exists). 3. RBAC spot-check with test credentials (xylolabs/solution_6231 is SuperAdmin): create a model via UI, toggle auto_queue, verify audit log rows. 4. Playwright: login, /inference page at 375×812 / 768×1024 / 1280×800, zero pageerror; session detail shows "분석 요청". 5. End-to-end: register the real gemma-4-12b preset (artifact google/gemma-4-12B-it), queue a job for a recent closed session, and watch the job go queued → running → completed via the ai-client host (or failed with a clear error if the GPU host is offline — verify the status surface works either way). 6. Auto-queue live check: toggle auto_queue on, wait for one bench-session close, confirm exactly one -10 priority job appears; toggle back off.


Self-Review Notes

  • Spec coverage: migration+index ✓ DTO/model/repo threading ✓ internal-route compat ✓ JWT routes ×7 ✓ RBAC split ✓ audit logging ✓ server-built payload ✓ auto-queue hook (single close site, best-effort, priority -10, dedup, reconcile excluded) ✓ UI (page + session action + both roles) ✓ i18n en/ko ✓ tests (RBAC, CRUD, submit, cross-facility, auto-queue dedup, cancel) ✓ docs EN/KO ✓ deploy+verify ✓.
  • Names consistent: auto_queue, list_active_auto_queue, enqueue_auto_jobs, idx_inference_jobs_session_model_live, /inference-admin/*, inferenceAdmin.ts client fns.
  • Known adaptation points are marked inline (cancel/find_by_id signatures, resolve_facility_filter idiom, api client helper names, JobResponse fields) — the implementer reconciles against the real code, keeping the spec'd semantics.