наблюдаемость: завершить базовый контур Community
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
mod integration {
|
||||
mod agents_usage;
|
||||
mod common;
|
||||
mod migrations;
|
||||
mod observability;
|
||||
mod operations_artifacts;
|
||||
mod workspace_access;
|
||||
}
|
||||
|
||||
@@ -228,32 +228,36 @@ async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest {
|
||||
log: &test_invocation_log(
|
||||
"log_usage_ok",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Ok,
|
||||
120,
|
||||
"2026-03-25T12:20:00Z",
|
||||
),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest {
|
||||
log: &test_invocation_log(
|
||||
"log_usage_err",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
240,
|
||||
"2026-03-25T12:21:00Z",
|
||||
),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest {
|
||||
log: &test_invocation_log(
|
||||
"log_usage_ok",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Ok,
|
||||
120,
|
||||
"2026-03-25T12:20:00Z",
|
||||
),
|
||||
})
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest {
|
||||
log: &test_invocation_log(
|
||||
"log_usage_err",
|
||||
&operation.id,
|
||||
Some(agent.id.clone()),
|
||||
crank_core::InvocationStatus::Error,
|
||||
240,
|
||||
"2026-03-25T12:21:00Z",
|
||||
),
|
||||
})
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
|
||||
let has_bindings = registry
|
||||
.has_published_agent_bindings_for_operation(&test_workspace_id(), &operation.id)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use crank_registry::PostgresRegistry;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_migration_is_versioned_and_safe_under_concurrent_startup() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_core_migration").await;
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
PostgresRegistry::connect(&database_url),
|
||||
PostgresRegistry::connect(&database_url),
|
||||
);
|
||||
let first = first.expect("first service startup must apply the migration");
|
||||
second.expect("second service startup must observe the applied migration");
|
||||
|
||||
let rows = sqlx::query(
|
||||
"select version, description, checksum from __crank_core_migrations order by version",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("migration ledger must be readable");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].get::<i32, _>("version"), 1);
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("description"),
|
||||
"community baseline"
|
||||
);
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("checksum"),
|
||||
"crank-community-baseline-v1"
|
||||
);
|
||||
|
||||
let approval_columns = sqlx::query(
|
||||
"select column_name
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'approval_requests'
|
||||
and column_name in ('execution_started_at', 'execution_attempts', 'request_fingerprint')",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("approval schema must be readable");
|
||||
assert_eq!(approval_columns.len(), 3);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crank_registry::{
|
||||
CreateInvocationLogRequest, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
|
||||
};
|
||||
|
||||
use super::common::{TestDatabase, test_invocation_log};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invocation_history_write_returns_typed_loss_without_error_details() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let log = test_invocation_log(
|
||||
"log_missing_owner",
|
||||
&crank_core::OperationId::new("op_missing"),
|
||||
None,
|
||||
crank_core::InvocationStatus::Ok,
|
||||
10,
|
||||
"2026-03-25T12:20:00Z",
|
||||
);
|
||||
|
||||
let outcome = registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InvocationHistoryWriteOutcome::Lost(crank_registry::InvocationHistoryLoss {
|
||||
category: InvocationHistoryLossCategory::InvalidRecord,
|
||||
})
|
||||
);
|
||||
database.cleanup().await;
|
||||
}
|
||||
@@ -38,6 +38,38 @@ fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_user_does_not_overwrite_an_existing_password() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let email = "bootstrap-owner@example.com";
|
||||
|
||||
let user_id = registry
|
||||
.upsert_bootstrap_user(email, "Bootstrap Owner", "initial-bootstrap-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.update_user_password(&user_id, "user-selected-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repeated_user_id = registry
|
||||
.upsert_bootstrap_user(email, "Changed Bootstrap Name", "changed-bootstrap-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = registry
|
||||
.get_auth_user_by_email(email)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repeated_user_id, user_id);
|
||||
assert_eq!(stored.password_hash, "user-selected-hash");
|
||||
assert_eq!(stored.user.display_name, "Bootstrap Owner");
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stores_and_finishes_yaml_import_jobs() {
|
||||
let database = TestDatabase::new().await;
|
||||
@@ -352,6 +384,24 @@ async fn creates_and_loads_user_sessions_with_typed_expiration() {
|
||||
assert_eq!(session.user.id, user_id);
|
||||
assert!(session.user.created_at.unix_timestamp() > 0);
|
||||
|
||||
registry.touch_user_session(&session_id).await.unwrap();
|
||||
let first_seen = sqlx::query_scalar::<_, OffsetDateTime>(
|
||||
"select last_seen_at from user_sessions where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
registry.touch_user_session(&session_id).await.unwrap();
|
||||
let second_seen = sqlx::query_scalar::<_, OffsetDateTime>(
|
||||
"select last_seen_at from user_sessions where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second_seen, first_seen);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
@@ -454,6 +504,40 @@ async fn manages_platform_api_key_read_paths() {
|
||||
Some(timestamp("2026-03-25T12:05:00Z"))
|
||||
);
|
||||
|
||||
registry
|
||||
.touch_platform_api_key(
|
||||
&workspace.id,
|
||||
&PlatformApiKeyId::new("key_01"),
|
||||
×tamp("2026-03-25T12:05:30Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let throttled = registry
|
||||
.list_platform_api_keys(&workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
throttled[0].api_key.last_used_at,
|
||||
Some(timestamp("2026-03-25T12:05:00Z"))
|
||||
);
|
||||
|
||||
registry
|
||||
.touch_platform_api_key(
|
||||
&workspace.id,
|
||||
&PlatformApiKeyId::new("key_01"),
|
||||
×tamp("2026-03-25T12:06:01Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let refreshed = registry
|
||||
.list_platform_api_keys(&workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refreshed[0].api_key.last_used_at,
|
||||
Some(timestamp("2026-03-25T12:06:01Z"))
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
@@ -587,7 +671,6 @@ async fn manages_approval_request_lifecycle() {
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:01Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
timestamp("2026-03-25T11:55:00Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -608,23 +691,10 @@ async fn manages_approval_request_lifecycle() {
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:10Z"),
|
||||
timestamp("2026-03-25T12:02:09Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fresh_claim.is_none());
|
||||
let recovered = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:03:00Z"),
|
||||
timestamp("2026-03-25T12:02:59Z"),
|
||||
timestamp("2026-03-25T12:02:30Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.approval.id, approval.id);
|
||||
assert_eq!(recovered.approval.status, ApprovalRequestStatus::Executing);
|
||||
|
||||
let completed = registry
|
||||
.finish_approval_request(FinishApprovalRequest {
|
||||
workspace_id: &workspace_id,
|
||||
@@ -637,7 +707,6 @@ async fn manages_approval_request_lifecycle() {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completed.approval.status, ApprovalRequestStatus::Completed);
|
||||
assert_eq!(
|
||||
completed.approval.response_payload,
|
||||
@@ -653,9 +722,81 @@ async fn manages_approval_request_lifecycle() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(completed_by_status.len(), 1);
|
||||
|
||||
let mut interrupted_approval = approval.clone();
|
||||
interrupted_approval.id = ApprovalRequestId::new("approval_interrupted_01");
|
||||
interrupted_approval.request_payload = json!({"amount": 150});
|
||||
interrupted_approval.created_at = timestamp("2026-03-25T12:02:10Z");
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &interrupted_approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.decide_approval_request(DecideApprovalRequest {
|
||||
workspace_id: &workspace_id,
|
||||
agent_id: &agent.id,
|
||||
approval_id: &interrupted_approval.id,
|
||||
status: ApprovalRequestStatus::Approved,
|
||||
decided_at: timestamp("2026-03-25T12:02:20Z"),
|
||||
decided_by_key_id: &approval_key.id,
|
||||
response_payload: Some(json!({"approve": "yes"})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry
|
||||
.claim_approval_request(
|
||||
&workspace_id,
|
||||
&agent.id,
|
||||
&interrupted_approval.id,
|
||||
timestamp("2026-03-25T12:02:21Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let recovered = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:03:00Z"),
|
||||
timestamp("2026-03-25T12:02:59Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recovered.is_none());
|
||||
|
||||
let interrupted = registry
|
||||
.fail_next_interrupted_approval_request(timestamp("2026-03-25T12:02:30Z"))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(interrupted.approval.id, interrupted_approval.id);
|
||||
assert_eq!(interrupted.approval.status, ApprovalRequestStatus::Failed);
|
||||
assert_eq!(
|
||||
completed_by_status[0].approval.status,
|
||||
ApprovalRequestStatus::Completed
|
||||
interrupted.approval.response_payload,
|
||||
Some(json!({
|
||||
"error": {
|
||||
"code": "approval_execution_outcome_unknown",
|
||||
"message": "execution was interrupted; the operation was not retried automatically"
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
let failed_by_status = registry
|
||||
.list_approval_requests(ListApprovalRequestsQuery {
|
||||
workspace_id: &workspace_id,
|
||||
status: Some(ApprovalRequestStatus::Failed),
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(failed_by_status.len(), 1);
|
||||
assert_eq!(
|
||||
failed_by_status[0].approval.status,
|
||||
ApprovalRequestStatus::Failed
|
||||
);
|
||||
|
||||
let pending_after_decision = registry
|
||||
|
||||
Reference in New Issue
Block a user