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, NOCo-Authored-By. After each commit:~/flash-shared/gitminer-cuda/mine_commit.sh 7, thengit 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 buildin BOTHfrontend/andfrontend-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.shtoapi.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.rs — list_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}/cancel → InferenceJobResponse
- [ ] 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 withJSON.parseon submit); per-row edit modal (artifact/config/is_active/auto_queue) and delete withConfirmDialog(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, attemptsn/max, error_message truncated, cancel button when status queued/running with ConfirmDialog).useQuerywithrefetchInterval: 10_000. Status filter select. -
All strings via
t('inference.*'); SVG icons only; responsive table wrapper as other pages (ScrollFadeContaineror 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.tsclient 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.