agent-style workers

This commit is contained in:
2026-03-21 08:27:20 -05:00
parent 8ba7e3bb84
commit 4d5a3b1bf5
11 changed files with 469 additions and 161 deletions

File diff suppressed because one or more lines are too long

View File

@@ -302,3 +302,163 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/agent/binary", get(download_agent_binary)) .route("/agent/binary", get(download_agent_binary))
.route("/agent/info", get(agent_info)) .route("/agent/info", get(agent_info))
} }
#[cfg(test)]
mod tests {
use super::*;
use attune_common::config::AgentConfig;
use axum::http::{HeaderMap, HeaderValue};
// ── validate_arch tests ─────────────────────────────────────────
#[test]
fn test_validate_arch_valid_x86_64() {
let result = validate_arch("x86_64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "x86_64");
}
#[test]
fn test_validate_arch_valid_aarch64() {
let result = validate_arch("aarch64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "aarch64");
}
#[test]
fn test_validate_arch_arm64_alias() {
// "arm64" is an alias for "aarch64"
let result = validate_arch("arm64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "aarch64");
}
#[test]
fn test_validate_arch_invalid() {
let result = validate_arch("mips");
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.0["error"], "Invalid architecture");
}
// ── validate_token tests ────────────────────────────────────────
/// Helper: build a minimal Config with the given agent config.
/// Only the `agent` field is relevant for `validate_token`.
fn test_config(agent: Option<AgentConfig>) -> attune_common::config::Config {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let config_path = format!("{}/../../config.test.yaml", manifest_dir);
let mut config = attune_common::config::Config::load_from_file(&config_path)
.expect("Failed to load test config");
config.agent = agent;
config
}
#[test]
fn test_validate_token_no_config() {
// When no agent config is set at all, no token is required.
let config = test_config(None);
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_no_bootstrap_token_configured() {
// Agent config exists but bootstrap_token is None → no token required.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: None,
}));
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_valid_from_header() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("s3cret-bootstrap".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert(
"x-agent-token",
HeaderValue::from_static("s3cret-bootstrap"),
);
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_valid_from_query() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("s3cret-bootstrap".to_string()),
}));
let headers = HeaderMap::new();
let query_token = Some("s3cret-bootstrap".to_string());
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_invalid() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("correct-token".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert("x-agent-token", HeaderValue::from_static("wrong-token"));
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0["error"], "Invalid token");
}
#[test]
fn test_validate_token_missing_when_required() {
// bootstrap_token is configured but caller provides nothing.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("required-token".to_string()),
}));
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0["error"], "Token required");
}
#[test]
fn test_validate_token_header_takes_precedence_over_query() {
// When both header and query provide a token, the header value is
// checked first (it appears first in the or_else chain). Provide a
// valid token in the header and an invalid one in the query — should
// succeed because the header matches.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("the-real-token".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert("x-agent-token", HeaderValue::from_static("the-real-token"));
let query_token = Some("wrong-token".to_string());
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,138 @@
//! Integration tests for agent binary distribution endpoints
//!
//! The agent endpoints (`/api/v1/agent/binary` and `/api/v1/agent/info`) are
//! intentionally unauthenticated — the agent needs to download its binary
//! before it has JWT credentials. An optional `bootstrap_token` can restrict
//! access, but that is validated inside the handler, not via RequireAuth
//! middleware.
//!
//! The test configuration (`config.test.yaml`) does NOT include an `agent`
//! section, so both endpoints return 503 Service Unavailable. This is the
//! correct behaviour: the endpoints are reachable (no 401/404 from middleware)
//! but the feature is not configured.
use axum::http::StatusCode;
#[allow(dead_code)]
mod helpers;
use helpers::TestContext;
// ── /api/v1/agent/info ──────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_info_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/info", None)
.await
.expect("Failed to make request");
// Agent config is not set in config.test.yaml, so the handler returns 503.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
assert_eq!(body["error"], "Not configured");
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_info_no_auth_required() {
// Verify that the endpoint is reachable WITHOUT any JWT token.
// If RequireAuth middleware were applied, this would return 401.
// Instead we expect 503 (not configured) — proving the endpoint
// is publicly accessible.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/info", None)
.await
.expect("Failed to make request");
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"agent/info should not require authentication"
);
// Should be 503 because agent config is absent.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
// ── /api/v1/agent/binary ────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary", None)
.await
.expect("Failed to make request");
// Agent config is not set in config.test.yaml, so the handler returns 503.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
assert_eq!(body["error"], "Not configured");
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_no_auth_required() {
// Same reasoning as test_agent_info_no_auth_required: the binary
// download endpoint must be publicly accessible (no RequireAuth).
// When no bootstrap_token is configured, any caller can reach the
// handler. We still get 503 because the agent feature itself is
// not configured in the test environment.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary", None)
.await
.expect("Failed to make request");
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"agent/binary should not require authentication when no bootstrap_token is configured"
);
// Should be 503 because agent config is absent.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_invalid_arch() {
// Architecture validation (`validate_arch`) rejects unsupported values
// with 400 Bad Request. However, in the handler the execution order is:
// 1. validate_token (passes — no bootstrap_token configured)
// 2. check agent config (fails with 503 — not configured)
// 3. validate_arch (never reached)
//
// So even with an invalid arch like "mips", we get 503 from the config
// check before the arch is ever validated. The arch validation is covered
// by unit tests in routes/agent.rs instead.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary?arch=mips", None)
.await
.expect("Failed to make request");
// 503 from the agent-config-not-set check, NOT 400 from arch validation.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}

View File

@@ -10,7 +10,7 @@ use crate::config::Config;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::models::Runtime; use crate::models::Runtime;
use crate::repositories::action::ActionRepository; use crate::repositories::action::ActionRepository;
use crate::repositories::runtime::RuntimeRepository; use crate::repositories::runtime::{self, RuntimeRepository};
use crate::repositories::FindById as _; use crate::repositories::FindById as _;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
@@ -370,20 +370,15 @@ impl PackEnvironmentManager {
// ======================================================================== // ========================================================================
async fn get_runtime(&self, runtime_id: i64) -> Result<Runtime> { async fn get_runtime(&self, runtime_id: i64) -> Result<Runtime> {
sqlx::query_as::<_, Runtime>( let query = format!(
r#" "SELECT {} FROM runtime WHERE id = $1",
SELECT id, ref, pack, pack_ref, description, name, runtime::SELECT_COLUMNS
distributions, installation, installers, execution_config, );
auto_detected, detection_config, sqlx::query_as::<_, Runtime>(&query)
created, updated .bind(runtime_id)
FROM runtime .fetch_one(&self.pool)
WHERE id = $1 .await
"#, .map_err(|e| Error::Internal(format!("Failed to fetch runtime: {}", e)))
)
.bind(runtime_id)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::Internal(format!("Failed to fetch runtime: {}", e)))
} }
fn runtime_requires_environment(&self, runtime: &Runtime) -> Result<bool> { fn runtime_requires_environment(&self, runtime: &Runtime) -> Result<bool> {

View File

@@ -63,19 +63,11 @@ impl FindById for RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtime = sqlx::query_as::<_, Runtime>( let query = format!("SELECT {} FROM runtime WHERE id = $1", SELECT_COLUMNS);
r#" let runtime = sqlx::query_as::<_, Runtime>(&query)
SELECT id, ref, pack, pack_ref, description, name, .bind(id)
distributions, installation, installers, execution_config, .fetch_optional(executor)
auto_detected, detection_config, .await?;
created, updated
FROM runtime
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(executor)
.await?;
Ok(runtime) Ok(runtime)
} }
@@ -87,19 +79,11 @@ impl FindByRef for RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtime = sqlx::query_as::<_, Runtime>( let query = format!("SELECT {} FROM runtime WHERE ref = $1", SELECT_COLUMNS);
r#" let runtime = sqlx::query_as::<_, Runtime>(&query)
SELECT id, ref, pack, pack_ref, description, name, .bind(ref_str)
distributions, installation, installers, execution_config, .fetch_optional(executor)
auto_detected, detection_config, .await?;
created, updated
FROM runtime
WHERE ref = $1
"#,
)
.bind(ref_str)
.fetch_optional(executor)
.await?;
Ok(runtime) Ok(runtime)
} }
@@ -111,18 +95,10 @@ impl List for RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtimes = sqlx::query_as::<_, Runtime>( let query = format!("SELECT {} FROM runtime ORDER BY ref ASC", SELECT_COLUMNS);
r#" let runtimes = sqlx::query_as::<_, Runtime>(&query)
SELECT id, ref, pack, pack_ref, description, name, .fetch_all(executor)
distributions, installation, installers, execution_config, .await?;
auto_detected, detection_config,
created, updated
FROM runtime
ORDER BY ref ASC
"#,
)
.fetch_all(executor)
.await?;
Ok(runtimes) Ok(runtimes)
} }
@@ -136,31 +112,28 @@ impl Create for RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtime = sqlx::query_as::<_, Runtime>( let query = format!(
r#" "INSERT INTO runtime (ref, pack, pack_ref, description, name, \
INSERT INTO runtime (ref, pack, pack_ref, description, name, distributions, installation, installers, execution_config, \
distributions, installation, installers, execution_config, auto_detected, detection_config) \
auto_detected, detection_config) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING {}",
RETURNING id, ref, pack, pack_ref, description, name, SELECT_COLUMNS
distributions, installation, installers, execution_config, );
auto_detected, detection_config, let runtime = sqlx::query_as::<_, Runtime>(&query)
created, updated .bind(&input.r#ref)
"#, .bind(input.pack)
) .bind(&input.pack_ref)
.bind(&input.r#ref) .bind(&input.description)
.bind(input.pack) .bind(&input.name)
.bind(&input.pack_ref) .bind(&input.distributions)
.bind(&input.description) .bind(&input.installation)
.bind(&input.name) .bind(serde_json::json!({}))
.bind(&input.distributions) .bind(&input.execution_config)
.bind(&input.installation) .bind(input.auto_detected)
.bind(serde_json::json!({})) .bind(&input.detection_config)
.bind(&input.execution_config) .fetch_one(executor)
.bind(input.auto_detected) .await?;
.bind(&input.detection_config)
.fetch_one(executor)
.await?;
Ok(runtime) Ok(runtime)
} }
@@ -252,12 +225,7 @@ impl Update for RuntimeRepository {
query.push(", updated = NOW() WHERE id = "); query.push(", updated = NOW() WHERE id = ");
query.push_bind(id); query.push_bind(id);
query.push( query.push(&format!(" RETURNING {}", SELECT_COLUMNS));
" RETURNING id, ref, pack, pack_ref, description, name, \
distributions, installation, installers, execution_config, \
auto_detected, detection_config, \
created, updated",
);
let runtime = query let runtime = query
.build_query_as::<Runtime>() .build_query_as::<Runtime>()
@@ -289,20 +257,14 @@ impl RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtimes = sqlx::query_as::<_, Runtime>( let query = format!(
r#" "SELECT {} FROM runtime WHERE pack = $1 ORDER BY ref ASC",
SELECT id, ref, pack, pack_ref, description, name, SELECT_COLUMNS
distributions, installation, installers, execution_config, );
auto_detected, detection_config, let runtimes = sqlx::query_as::<_, Runtime>(&query)
created, updated .bind(pack_id)
FROM runtime .fetch_all(executor)
WHERE pack = $1 .await?;
ORDER BY ref ASC
"#,
)
.bind(pack_id)
.fetch_all(executor)
.await?;
Ok(runtimes) Ok(runtimes)
} }
@@ -312,20 +274,14 @@ impl RuntimeRepository {
where where
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let runtime = sqlx::query_as::<_, Runtime>( let query = format!(
r#" "SELECT {} FROM runtime WHERE LOWER(name) = LOWER($1) LIMIT 1",
SELECT id, ref, pack, pack_ref, description, name, SELECT_COLUMNS
distributions, installation, installers, execution_config, );
auto_detected, detection_config, let runtime = sqlx::query_as::<_, Runtime>(&query)
created, updated .bind(name)
FROM runtime .fetch_optional(executor)
WHERE LOWER(name) = LOWER($1) .await?;
LIMIT 1
"#,
)
.bind(name)
.fetch_optional(executor)
.await?;
Ok(runtime) Ok(runtime)
} }

View File

@@ -12,6 +12,7 @@
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::models::Runtime; use crate::models::Runtime;
use crate::repositories::runtime::SELECT_COLUMNS;
use serde_json::json; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
@@ -161,18 +162,10 @@ impl RuntimeDetector {
info!("Querying database for runtime definitions..."); info!("Querying database for runtime definitions...");
// Query all runtimes from database // Query all runtimes from database
let runtimes = sqlx::query_as::<_, Runtime>( let query = format!("SELECT {} FROM runtime ORDER BY ref", SELECT_COLUMNS);
r#" let runtimes = sqlx::query_as::<_, Runtime>(&query)
SELECT id, ref, pack, pack_ref, description, name, .fetch_all(&self.pool)
distributions, installation, installers, execution_config, .await?;
auto_detected, detection_config,
created, updated
FROM runtime
ORDER BY ref
"#,
)
.fetch_all(&self.pool)
.await?;
info!("Found {} runtime(s) in database", runtimes.len()); info!("Found {} runtime(s) in database", runtimes.len());

View File

@@ -113,6 +113,10 @@ async fn main() -> Result<()> {
let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect(); let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect();
let runtime_csv = runtime_list.join(","); let runtime_csv = runtime_list.join(",");
info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv); info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv);
// SAFETY: std::env::set_var is safe in Rust 2021 edition. If upgrading
// to edition 2024+, this call will need to be wrapped in `unsafe {}`.
// It's sound here because detection runs single-threaded before tokio
// starts any worker tasks.
std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv); std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv);
// Stash for Phase 2: pass to WorkerService for rich capability registration // Stash for Phase 2: pass to WorkerService for rich capability registration
@@ -133,9 +137,10 @@ async fn main() -> Result<()> {
println!(); println!();
let detected = detect_runtimes(); let detected = detect_runtimes();
print_detection_report(&detected); print_detection_report(&detected);
} else if let Some(ref detected) = agent_detected_runtimes {
print_detection_report(detected);
} else { } else {
// We already ran detection above; re-run to get a fresh Vec for the report // No detection ran (empty results), run it fresh
// (the previous one was consumed by env var setup).
let detected = detect_runtimes(); let detected = detect_runtimes();
print_detection_report(&detected); print_detection_report(&detected);
} }
@@ -144,6 +149,7 @@ async fn main() -> Result<()> {
// --- Phase 2: Load configuration --- // --- Phase 2: Load configuration ---
if let Some(config_path) = args.config { if let Some(config_path) = args.config {
// SAFETY: std::env::set_var is safe in Rust 2021 edition. See note above.
std::env::set_var("ATTUNE_CONFIG", config_path); std::env::set_var("ATTUNE_CONFIG", config_path);
} }

View File

@@ -19,6 +19,7 @@ use attune_common::models::runtime::RuntimeExecutionConfig;
use attune_common::models::{runtime::Runtime as RuntimeModel, Action, Execution, ExecutionStatus}; use attune_common::models::{runtime::Runtime as RuntimeModel, Action, Execution, ExecutionStatus};
use attune_common::repositories::artifact::{ArtifactRepository, ArtifactVersionRepository}; use attune_common::repositories::artifact::{ArtifactRepository, ArtifactVersionRepository};
use attune_common::repositories::execution::{ExecutionRepository, UpdateExecutionInput}; use attune_common::repositories::execution::{ExecutionRepository, UpdateExecutionInput};
use attune_common::repositories::runtime::SELECT_COLUMNS as RUNTIME_SELECT_COLUMNS;
use attune_common::repositories::runtime_version::RuntimeVersionRepository; use attune_common::repositories::runtime_version::RuntimeVersionRepository;
use attune_common::repositories::{FindById, Update}; use attune_common::repositories::{FindById, Update};
use attune_common::version_matching::select_best_version; use attune_common::version_matching::select_best_version;
@@ -410,16 +411,14 @@ impl ActionExecutor {
// Load runtime information if specified // Load runtime information if specified
let runtime_record = if let Some(runtime_id) = action.runtime { let runtime_record = if let Some(runtime_id) = action.runtime {
match sqlx::query_as::<_, RuntimeModel>( let query = format!(
r#"SELECT id, ref, pack, pack_ref, description, name, "SELECT {} FROM runtime WHERE id = $1",
distributions, installation, installers, execution_config, RUNTIME_SELECT_COLUMNS
auto_detected, detection_config, );
created, updated match sqlx::query_as::<_, RuntimeModel>(&query)
FROM runtime WHERE id = $1"#, .bind(runtime_id)
) .fetch_optional(&self.pool)
.bind(runtime_id) .await
.fetch_optional(&self.pool)
.await
{ {
Ok(Some(runtime)) => { Ok(Some(runtime)) => {
debug!( debug!(

View File

@@ -393,4 +393,72 @@ mod tests {
registration.deregister().await.unwrap(); registration.deregister().await.unwrap();
} }
#[test]
fn test_detected_runtimes_json_structure() {
// Test the JSON structure that set_detected_runtimes builds
let runtimes = vec![
DetectedRuntime {
name: "python".to_string(),
path: "/usr/bin/python3".to_string(),
version: Some("3.12.1".to_string()),
},
DetectedRuntime {
name: "shell".to_string(),
path: "/bin/bash".to_string(),
version: None,
},
];
let interpreters: Vec<serde_json::Value> = runtimes
.iter()
.map(|rt| {
json!({
"name": rt.name,
"path": rt.path,
"version": rt.version,
})
})
.collect();
let json_value = json!(interpreters);
// Verify structure
let arr = json_value.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "python");
assert_eq!(arr[0]["path"], "/usr/bin/python3");
assert_eq!(arr[0]["version"], "3.12.1");
assert_eq!(arr[1]["name"], "shell");
assert_eq!(arr[1]["path"], "/bin/bash");
assert!(arr[1]["version"].is_null());
}
#[test]
fn test_detected_runtimes_empty() {
let runtimes: Vec<DetectedRuntime> = vec![];
let interpreters: Vec<serde_json::Value> = runtimes
.iter()
.map(|rt| {
json!({
"name": rt.name,
"path": rt.path,
"version": rt.version,
})
})
.collect();
let json_value = json!(interpreters);
assert_eq!(json_value.as_array().unwrap().len(), 0);
}
#[test]
fn test_agent_mode_capability_value() {
// Verify the JSON value for agent_mode capability
let value = json!(true);
assert_eq!(value, true);
let value = json!(false);
assert_eq!(value, false);
}
} }

View File

@@ -131,6 +131,17 @@ CREATE TABLE runtime (
-- {manifest_path} - absolute path to the dependency manifest file -- {manifest_path} - absolute path to the dependency manifest file
execution_config JSONB NOT NULL DEFAULT '{}'::jsonb, execution_config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Whether this runtime was auto-registered by an agent
-- (vs. loaded from a pack's YAML file during pack registration)
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
-- Detection metadata for auto-discovered runtimes.
-- Stores how the agent discovered this runtime (binary path, version, etc.)
-- enables re-verification on restart.
-- Example: { "detected_path": "/usr/bin/ruby", "detected_name": "ruby",
-- "detected_version": "3.3.0" }
detection_config JSONB NOT NULL DEFAULT '{}'::jsonb,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(), created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -145,6 +156,8 @@ CREATE INDEX idx_runtime_created ON runtime(created DESC);
CREATE INDEX idx_runtime_name ON runtime(name); CREATE INDEX idx_runtime_name ON runtime(name);
CREATE INDEX idx_runtime_verification ON runtime USING GIN ((distributions->'verification')); CREATE INDEX idx_runtime_verification ON runtime USING GIN ((distributions->'verification'));
CREATE INDEX idx_runtime_execution_config ON runtime USING GIN (execution_config); CREATE INDEX idx_runtime_execution_config ON runtime USING GIN (execution_config);
CREATE INDEX idx_runtime_auto_detected ON runtime(auto_detected);
CREATE INDEX idx_runtime_detection_config ON runtime USING GIN (detection_config);
-- Trigger -- Trigger
CREATE TRIGGER update_runtime_updated CREATE TRIGGER update_runtime_updated
@@ -160,6 +173,8 @@ COMMENT ON COLUMN runtime.distributions IS 'Runtime distribution metadata includ
COMMENT ON COLUMN runtime.installation IS 'Installation requirements and instructions including package managers and setup steps'; COMMENT ON COLUMN runtime.installation IS 'Installation requirements and instructions including package managers and setup steps';
COMMENT ON COLUMN runtime.installers IS 'Array of installer actions to create pack-specific runtime environments. Each installer defines commands to set up isolated environments (e.g., Python venv, npm install).'; COMMENT ON COLUMN runtime.installers IS 'Array of installer actions to create pack-specific runtime environments. Each installer defines commands to set up isolated environments (e.g., Python venv, npm install).';
COMMENT ON COLUMN runtime.execution_config IS 'Execution configuration: interpreter, environment setup, and dependency management. Drives how the worker executes actions and how pack install sets up environments.'; COMMENT ON COLUMN runtime.execution_config IS 'Execution configuration: interpreter, environment setup, and dependency management. Drives how the worker executes actions and how pack install sets up environments.';
COMMENT ON COLUMN runtime.auto_detected IS 'Whether this runtime was auto-registered by an agent (true) vs. loaded from a pack YAML (false)';
COMMENT ON COLUMN runtime.detection_config IS 'Detection metadata for auto-discovered runtimes: binaries probed, version regex, detected path/version';
-- ============================================================================ -- ============================================================================
-- RUNTIME VERSION TABLE -- RUNTIME VERSION TABLE

View File

@@ -1,22 +0,0 @@
-- Migration: 000012_agent_runtime_detection
-- Adds columns to support agent auto-detected runtimes
-- Track whether a runtime was auto-registered by an agent
-- (vs. loaded from a pack's YAML file during pack registration)
ALTER TABLE runtime ADD COLUMN IF NOT EXISTS auto_detected BOOLEAN NOT NULL DEFAULT FALSE;
-- Store detection configuration for auto-discovered runtimes.
-- Used by agents to identify how they discovered the runtime and
-- enables re-verification on restart.
-- Example: { "binaries": ["ruby", "ruby3.2"], "version_command": "--version",
-- "version_regex": "ruby (\\d+\\.\\d+\\.\\d+)",
-- "detected_path": "/usr/bin/ruby",
-- "detected_version": "3.3.0" }
ALTER TABLE runtime ADD COLUMN IF NOT EXISTS detection_config JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Index for filtering auto-detected vs. pack-registered runtimes
CREATE INDEX IF NOT EXISTS idx_runtime_auto_detected ON runtime(auto_detected);
-- Comments
COMMENT ON COLUMN runtime.auto_detected IS 'Whether this runtime was auto-registered by an agent (true) vs. loaded from a pack YAML (false)';
COMMENT ON COLUMN runtime.detection_config IS 'Detection metadata for auto-discovered runtimes: binaries probed, version regex, detected path/version';