11 Commits

Author SHA1 Message Date
f93e9229d2 ha executor
Some checks failed
CI / Rustfmt (pull_request) Successful in 19s
CI / Cargo Audit & Deny (pull_request) Successful in 33s
CI / Security Blocking Checks (pull_request) Successful in 5s
CI / Web Blocking Checks (pull_request) Successful in 49s
CI / Web Advisory Checks (pull_request) Successful in 33s
CI / Clippy (pull_request) Has been cancelled
CI / Security Advisory Checks (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
2026-04-02 17:15:59 -05:00
8e91440f23 [WIP] making executor ha 2026-04-02 11:33:26 -05:00
8278030699 fixing tests, making clippy happy
Some checks failed
CI / Rustfmt (push) Successful in 19s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Security Blocking Checks (push) Successful in 5s
CI / Web Advisory Checks (push) Successful in 28s
CI / Web Blocking Checks (push) Successful in 52s
Publish Images / Resolve Publish Metadata (push) Successful in 0s
CI / Security Advisory Checks (push) Successful in 23s
CI / Clippy (push) Successful in 2m4s
Publish Images / Publish Docker Dist Bundle (push) Successful in 4s
Publish Images / Publish web (amd64) (push) Successful in 45s
Publish Images / Publish web (arm64) (push) Successful in 3m32s
CI / Tests (push) Failing after 8m25s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m12s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m39s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish executor (amd64) (push) Successful in 40s
Publish Images / Publish api (amd64) (push) Successful in 30s
Publish Images / Publish notifier (amd64) (push) Successful in 41s
Publish Images / Publish agent (arm64) (push) Successful in 52s
Publish Images / Publish api (arm64) (push) Successful in 1m56s
Publish Images / Publish executor (arm64) (push) Successful in 1m57s
Publish Images / Publish notifier (arm64) (push) Successful in 1m50s
Publish Images / Publish manifest attune/agent (push) Successful in 15s
Publish Images / Publish manifest attune/api (push) Failing after 30s
Publish Images / Publish manifest attune/executor (push) Successful in 42s
Publish Images / Publish manifest attune/web (push) Failing after 17s
Publish Images / Publish manifest attune/notifier (push) Failing after 14m44s
2026-04-02 09:17:21 -05:00
b34617ded1 npm audit fix
Some checks failed
CI / Rustfmt (push) Successful in 19s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Security Blocking Checks (push) Successful in 5s
CI / Web Blocking Checks (push) Successful in 53s
CI / Web Advisory Checks (push) Successful in 34s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 25s
CI / Clippy (push) Failing after 1m46s
Publish Images / Publish Docker Dist Bundle (push) Successful in 4s
Publish Images / Publish web (amd64) (push) Successful in 44s
Publish Images / Publish web (arm64) (push) Successful in 3m21s
CI / Tests (push) Failing after 6m7s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m13s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m39s
Publish Images / Publish agent (amd64) (push) Successful in 21s
Publish Images / Publish executor (amd64) (push) Failing after 45s
Publish Images / Publish api (amd64) (push) Failing after 45s
Publish Images / Publish notifier (amd64) (push) Failing after 43s
Publish Images / Publish agent (arm64) (push) Successful in 59s
Publish Images / Publish executor (arm64) (push) Successful in 1m52s
Publish Images / Publish api (arm64) (push) Successful in 1m58s
Publish Images / Publish notifier (arm64) (push) Successful in 1m52s
Publish Images / Publish manifest attune/agent (push) Has been skipped
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
2026-04-02 08:06:55 -05:00
b6446cc574 queueing fixes 2026-04-02 08:06:02 -05:00
cf82de87ea removing useless root-level package.json
Some checks failed
CI / Rustfmt (push) Successful in 19s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Security Blocking Checks (push) Successful in 5s
CI / Web Blocking Checks (push) Successful in 50s
CI / Web Advisory Checks (push) Successful in 34s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 25s
CI / Clippy (push) Failing after 1m41s
Publish Images / Publish Docker Dist Bundle (push) Successful in 4s
Publish Images / Publish web (amd64) (push) Successful in 43s
Publish Images / Publish web (arm64) (push) Successful in 3m17s
CI / Tests (push) Failing after 6m0s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m17s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m40s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish notifier (amd64) (push) Failing after 11s
Publish Images / Publish executor (amd64) (push) Successful in 46s
Publish Images / Publish api (amd64) (push) Successful in 46s
Publish Images / Publish agent (arm64) (push) Successful in 56s
Publish Images / Publish api (arm64) (push) Successful in 2m4s
Publish Images / Publish executor (arm64) (push) Successful in 2m3s
Publish Images / Publish notifier (arm64) (push) Successful in 1m56s
Publish Images / Publish manifest attune/agent (push) Has been skipped
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
2026-04-01 20:40:13 -05:00
a4c303ec84 merging semgrep-scan 2026-04-01 20:38:18 -05:00
a0f59114a3 Merge branch 'semgrep-scan' 2026-04-01 20:37:39 -05:00
104dcbb1b1 [WIP] client action streaming 2026-04-01 20:23:56 -05:00
b342005e17 addressing some semgrep issues 2026-04-01 19:27:37 -05:00
4b525f4641 attempting to fix build pipeline failures
All checks were successful
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 35s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Blocking Checks (push) Successful in 50s
CI / Web Advisory Checks (push) Successful in 35s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 37s
CI / Clippy (push) Successful in 2m3s
Publish Images / Publish web (amd64) (push) Successful in 42s
Publish Images / Publish web (arm64) (push) Successful in 3m25s
CI / Tests (push) Successful in 8m51s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m32s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m22s
Publish Images / Publish agent (amd64) (push) Successful in 21s
Publish Images / Publish notifier (amd64) (push) Successful in 37s
Publish Images / Publish executor (amd64) (push) Successful in 41s
Publish Images / Publish api (amd64) (push) Successful in 41s
Publish Images / Publish agent (arm64) (push) Successful in 55s
Publish Images / Publish api (arm64) (push) Successful in 1m58s
Publish Images / Publish executor (arm64) (push) Successful in 1m53s
Publish Images / Publish notifier (arm64) (push) Successful in 1m53s
Publish Images / Publish manifest attune/agent (push) Successful in 7s
Publish Images / Publish manifest attune/api (push) Successful in 16s
Publish Images / Publish manifest attune/executor (push) Successful in 10s
Publish Images / Publish manifest attune/notifier (push) Successful in 8s
Publish Images / Publish manifest attune/web (push) Successful in 7s
Publish Images / Publish Docker Dist Bundle (push) Successful in 4s
2026-03-28 14:21:09 -05:00
76 changed files with 8269 additions and 1893 deletions

0
.codex Normal file
View File

View File

@@ -53,6 +53,7 @@ jobs:
registry: ${{ steps.meta.outputs.registry }}
namespace: ${{ steps.meta.outputs.namespace }}
registry_plain_http: ${{ steps.meta.outputs.registry_plain_http }}
gitea_base_url: ${{ steps.meta.outputs.gitea_base_url }}
image_tag: ${{ steps.meta.outputs.image_tag }}
image_tags: ${{ steps.meta.outputs.image_tags }}
artifact_ref_base: ${{ steps.meta.outputs.artifact_ref_base }}
@@ -99,6 +100,12 @@ jobs:
registry_plain_http="$registry_plain_http_default"
fi
if [ "$registry_plain_http" = "true" ]; then
gitea_base_url="http://${registry}"
else
gitea_base_url="https://${registry}"
fi
short_sha="$(printf '%s' "${{ github.sha }}" | cut -c1-12)"
ref_type="${{ github.ref_type }}"
ref_name="${{ github.ref_name }}"
@@ -117,6 +124,7 @@ jobs:
echo "registry=$registry"
echo "namespace=$namespace"
echo "registry_plain_http=$registry_plain_http"
echo "gitea_base_url=$gitea_base_url"
echo "image_tag=$version"
echo "image_tags=$image_tags"
echo "artifact_ref_base=$artifact_ref_base"
@@ -321,6 +329,7 @@ jobs:
set -euo pipefail
push_args=()
artifact_file="attune-binaries-${{ matrix.arch }}.tar.gz"
artifact_ref="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/${ARTIFACT_REPOSITORY}-${{ matrix.arch }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}"
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
push_args+=(--plain-http)
@@ -328,9 +337,15 @@ jobs:
cp "dist/${artifact_file}" "${artifact_file}"
echo "Pushing binary bundle artifact"
echo " artifact_ref: ${artifact_ref}"
echo " registry_url: ${{ needs.metadata.outputs.gitea_base_url }}/v2/"
echo " manifest_url: ${{ needs.metadata.outputs.gitea_base_url }}/v2/${{ needs.metadata.outputs.namespace }}/${ARTIFACT_REPOSITORY}-${{ matrix.arch }}/manifests/rust-binaries-${{ needs.metadata.outputs.image_tag }}"
echo " artifact_file: ${artifact_file}"
oras push \
"${push_args[@]}" \
"${{ needs.metadata.outputs.artifact_ref_base }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}" \
"${artifact_ref}" \
--artifact-type application/vnd.attune.rust-binaries.v1 \
"${artifact_file}:application/vnd.attune.rust-binaries.layer.v1.tar+gzip"
@@ -341,13 +356,19 @@ jobs:
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
run: |
set -euo pipefail
api_base="${{ github.server_url }}/api/v1"
package_name="${ARTIFACT_REPOSITORY}"
api_base="${{ needs.metadata.outputs.gitea_base_url }}/api/v1"
package_name="${ARTIFACT_REPOSITORY}-${{ matrix.arch }}"
encoded_package_name="$(PACKAGE_NAME="${package_name}" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PACKAGE_NAME"], safe=""))')"
link_url="${api_base}/packages/${{ needs.metadata.outputs.namespace }}/container/${encoded_package_name}/-/link/${REPOSITORY_NAME}"
echo "Linking binary bundle package"
echo " api_base: ${api_base}"
echo " package_name: ${package_name}"
echo " link_url: ${link_url}"
status_code="$(curl -sS -o /tmp/package-link-response.txt -w '%{http_code}' -X POST \
-u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
"${api_base}/packages/${{ needs.metadata.outputs.namespace }}/container/${encoded_package_name}/-/link/${REPOSITORY_NAME}")"
"${link_url}")"
case "${status_code}" in
200|201|204|409)
@@ -380,12 +401,57 @@ jobs:
set -euo pipefail
bash scripts/package-docker-dist.sh docker/distributable artifacts/attune-docker-dist.tar.gz
- name: Upload docker dist archive
uses: actions/upload-artifact@v4
with:
name: attune-docker-dist-${{ needs.metadata.outputs.image_tag }}
path: artifacts/attune-docker-dist.tar.gz
if-no-files-found: error
- name: Publish docker dist generic package
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_PASSWORD:-}" ]; then
echo "CONTAINER_REGISTRY_USERNAME and CONTAINER_REGISTRY_PASSWORD are required to publish the docker dist package"
exit 1
fi
owner="${{ needs.metadata.outputs.namespace }}"
package_name="attune-docker-dist"
package_version="${{ needs.metadata.outputs.image_tag }}"
file_name="attune-docker-dist.tar.gz"
api_base="${{ needs.metadata.outputs.gitea_base_url }}/api/packages"
package_url="${api_base}/${owner}/generic/${package_name}/${package_version}/${file_name}"
# Generic packages reject overwriting the same file name. Delete it first on reruns.
delete_status="$(curl -sS -o /tmp/docker-dist-delete-response.txt -w '%{http_code}' \
-u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
-X DELETE \
"${package_url}")"
case "${delete_status}" in
204|404)
;;
*)
echo "Failed to prepare generic package upload target"
cat /tmp/docker-dist-delete-response.txt
exit 1
;;
esac
upload_status="$(curl -sS -o /tmp/docker-dist-upload-response.txt -w '%{http_code}' \
-u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
--upload-file artifacts/attune-docker-dist.tar.gz \
-X PUT \
"${package_url}")"
case "${upload_status}" in
201)
;;
*)
echo "Failed to publish docker dist generic package"
cat /tmp/docker-dist-upload-response.txt
exit 1
;;
esac
- name: Attach docker dist archive to release
if: github.ref_type == 'tag'
@@ -401,7 +467,7 @@ jobs:
exit 1
fi
api_base="${{ github.server_url }}/api/v1"
api_base="${{ needs.metadata.outputs.gitea_base_url }}/api/v1"
owner_repo="${{ github.repository }}"
tag_name="${{ github.ref_name }}"
archive_path="artifacts/attune-docker-dist.tar.gz"
@@ -647,7 +713,7 @@ jobs:
run: |
set -euo pipefail
pull_args=()
artifact_ref="${{ needs.metadata.outputs.artifact_ref_base }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}"
artifact_ref="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/${ARTIFACT_REPOSITORY}-${{ matrix.arch }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}"
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
pull_args+=(--plain-http)
@@ -655,6 +721,8 @@ jobs:
echo "Pulling binary bundle artifact"
echo " ref: ${artifact_ref}"
echo " registry_url: ${{ needs.metadata.outputs.gitea_base_url }}/v2/"
echo " manifest_url: ${{ needs.metadata.outputs.gitea_base_url }}/v2/${{ needs.metadata.outputs.namespace }}/${ARTIFACT_REPOSITORY}-${{ matrix.arch }}/manifests/rust-binaries-${{ needs.metadata.outputs.image_tag }}"
echo " arch: ${{ matrix.arch }}"
echo " plain_http: ${{ needs.metadata.outputs.registry_plain_http }}"
@@ -754,7 +822,7 @@ jobs:
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
run: |
set -euo pipefail
api_base="${{ github.server_url }}/api/v1"
api_base="${{ needs.metadata.outputs.gitea_base_url }}/api/v1"
package_name="${{ matrix.image.repository }}"
encoded_package_name="$(PACKAGE_NAME="${package_name}" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PACKAGE_NAME"], safe=""))')"
@@ -904,7 +972,7 @@ jobs:
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
run: |
set -euo pipefail
api_base="${{ github.server_url }}/api/v1"
api_base="${{ needs.metadata.outputs.gitea_base_url }}/api/v1"
package_name="attune/web"
encoded_package_name="$(PACKAGE_NAME="${package_name}" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PACKAGE_NAME"], safe=""))')"

View File

@@ -4,3 +4,6 @@ web/node_modules/
web/src/api/
packs.dev/
packs.external/
tests/
docs/
*.md

2
Cargo.lock generated
View File

@@ -528,6 +528,7 @@ dependencies = [
"mockito",
"predicates",
"reqwest 0.13.2",
"reqwest-eventsource",
"serde",
"serde_json",
"serde_yaml_ng",
@@ -579,6 +580,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"uuid",
"validator",

View File

@@ -62,6 +62,8 @@ pack_registry:
enabled: true
default_registry: https://registry.attune.example.com
cache_ttl: 300
allowed_source_hosts:
- registry.attune.example.com
# Test worker configuration
# worker:

View File

@@ -2,6 +2,7 @@
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
http::StatusCode,
response::{
sse::{Event, KeepAlive, Sse},
@@ -13,6 +14,7 @@ use axum::{
use chrono::Utc;
use futures::stream::{Stream, StreamExt};
use std::sync::Arc;
use std::time::Duration;
use tokio_stream::wrappers::BroadcastStream;
use attune_common::models::enums::ExecutionStatus;
@@ -32,7 +34,10 @@ use attune_common::workflow::{CancellationPolicy, WorkflowDefinition};
use sqlx::Row;
use crate::{
auth::middleware::RequireAuth,
auth::{
jwt::{validate_token, Claims, JwtConfig, TokenType},
middleware::{AuthenticatedUser, RequireAuth},
},
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
@@ -46,6 +51,9 @@ use crate::{
};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
const LOG_STREAM_POLL_INTERVAL: Duration = Duration::from_millis(250);
const LOG_STREAM_READ_CHUNK_SIZE: usize = 64 * 1024;
/// Create a new execution (manual execution)
///
/// This endpoint allows directly executing an action without a trigger or rule.
@@ -925,6 +933,398 @@ pub async fn stream_execution_updates(
Ok(Sse::new(filtered_stream).keep_alive(KeepAlive::default()))
}
#[derive(serde::Deserialize)]
pub struct StreamExecutionLogParams {
pub token: Option<String>,
pub offset: Option<u64>,
}
#[derive(Clone, Copy)]
enum ExecutionLogStream {
Stdout,
Stderr,
}
impl ExecutionLogStream {
fn parse(name: &str) -> Result<Self, ApiError> {
match name {
"stdout" => Ok(Self::Stdout),
"stderr" => Ok(Self::Stderr),
_ => Err(ApiError::BadRequest(format!(
"Unsupported log stream '{}'. Expected 'stdout' or 'stderr'.",
name
))),
}
}
fn file_name(self) -> &'static str {
match self {
Self::Stdout => "stdout.log",
Self::Stderr => "stderr.log",
}
}
}
enum ExecutionLogTailState {
WaitingForFile {
full_path: std::path::PathBuf,
execution_id: i64,
},
SendInitial {
full_path: std::path::PathBuf,
execution_id: i64,
offset: u64,
pending_utf8: Vec<u8>,
},
Tail {
full_path: std::path::PathBuf,
execution_id: i64,
offset: u64,
idle_polls: u32,
pending_utf8: Vec<u8>,
},
Finished,
}
/// Stream stdout/stderr for an execution as SSE.
///
/// This tails the worker's live log files directly from the shared artifacts
/// volume. The file may not exist yet when the worker has not emitted any
/// output, so the stream waits briefly for it to appear.
#[utoipa::path(
get,
path = "/api/v1/executions/{id}/logs/{stream}/stream",
tag = "executions",
params(
("id" = i64, Path, description = "Execution ID"),
("stream" = String, Path, description = "Log stream name: stdout or stderr"),
("token" = String, Query, description = "JWT access token for authentication"),
),
responses(
(status = 200, description = "SSE stream of execution log content", content_type = "text/event-stream"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Execution not found"),
),
)]
pub async fn stream_execution_log(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path((id, stream_name)): Path<(i64, String)>,
Query(params): Query<StreamExecutionLogParams>,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, ApiError> {
let authenticated_user =
authenticate_execution_log_stream_user(&state, &headers, user, params.token.as_deref())?;
validate_execution_log_stream_user(&authenticated_user, id)?;
let execution = ExecutionRepository::find_by_id(&state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Execution with ID {} not found", id)))?;
authorize_execution_log_stream(&state, &authenticated_user, &execution).await?;
let stream_name = ExecutionLogStream::parse(&stream_name)?;
let full_path = std::path::PathBuf::from(&state.config.artifacts_dir)
.join(format!("execution_{}", id))
.join(stream_name.file_name());
let db = state.db.clone();
let initial_state = ExecutionLogTailState::WaitingForFile {
full_path,
execution_id: id,
};
let start_offset = params.offset.unwrap_or(0);
let stream = futures::stream::unfold(initial_state, move |state| {
let db = db.clone();
async move {
match state {
ExecutionLogTailState::Finished => None,
ExecutionLogTailState::WaitingForFile {
full_path,
execution_id,
} => {
if full_path.exists() {
Some((
Ok(Event::default().event("waiting").data("Log file found")),
ExecutionLogTailState::SendInitial {
full_path,
execution_id,
offset: start_offset,
pending_utf8: Vec::new(),
},
))
} else if execution_log_execution_terminal(&db, execution_id).await {
Some((
Ok(Event::default().event("done").data("")),
ExecutionLogTailState::Finished,
))
} else {
tokio::time::sleep(LOG_STREAM_POLL_INTERVAL).await;
Some((
Ok(Event::default()
.event("waiting")
.data("Waiting for log output")),
ExecutionLogTailState::WaitingForFile {
full_path,
execution_id,
},
))
}
}
ExecutionLogTailState::SendInitial {
full_path,
execution_id,
offset,
pending_utf8,
} => {
let pending_utf8_on_empty = pending_utf8.clone();
match read_log_chunk(
&full_path,
offset,
LOG_STREAM_READ_CHUNK_SIZE,
pending_utf8,
)
.await
{
Some((content, new_offset, pending_utf8)) => Some((
Ok(Event::default()
.id(new_offset.to_string())
.event("content")
.data(content)),
ExecutionLogTailState::SendInitial {
full_path,
execution_id,
offset: new_offset,
pending_utf8,
},
)),
None => Some((
Ok(Event::default().comment("initial-catchup-complete")),
ExecutionLogTailState::Tail {
full_path,
execution_id,
offset,
idle_polls: 0,
pending_utf8: pending_utf8_on_empty,
},
)),
}
}
ExecutionLogTailState::Tail {
full_path,
execution_id,
offset,
idle_polls,
pending_utf8,
} => {
let pending_utf8_on_empty = pending_utf8.clone();
match read_log_chunk(
&full_path,
offset,
LOG_STREAM_READ_CHUNK_SIZE,
pending_utf8,
)
.await
{
Some((append, new_offset, pending_utf8)) => Some((
Ok(Event::default()
.id(new_offset.to_string())
.event("append")
.data(append)),
ExecutionLogTailState::Tail {
full_path,
execution_id,
offset: new_offset,
idle_polls: 0,
pending_utf8,
},
)),
None => {
let terminal =
execution_log_execution_terminal(&db, execution_id).await;
if terminal && idle_polls >= 2 {
Some((
Ok(Event::default().event("done").data("Execution complete")),
ExecutionLogTailState::Finished,
))
} else {
tokio::time::sleep(LOG_STREAM_POLL_INTERVAL).await;
Some((
Ok(Event::default()
.event("waiting")
.data("Waiting for log output")),
ExecutionLogTailState::Tail {
full_path,
execution_id,
offset,
idle_polls: idle_polls + 1,
pending_utf8: pending_utf8_on_empty,
},
))
}
}
}
}
}
}
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn read_log_chunk(
path: &std::path::Path,
offset: u64,
max_bytes: usize,
mut pending_utf8: Vec<u8>,
) -> Option<(String, u64, Vec<u8>)> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(path).await.ok()?;
let metadata = file.metadata().await.ok()?;
if metadata.len() <= offset {
return None;
}
file.seek(std::io::SeekFrom::Start(offset)).await.ok()?;
let bytes_to_read = ((metadata.len() - offset) as usize).min(max_bytes);
let mut buf = vec![0u8; bytes_to_read];
let read = file.read(&mut buf).await.ok()?;
buf.truncate(read);
if buf.is_empty() {
return None;
}
pending_utf8.extend_from_slice(&buf);
let (content, pending_utf8) = decode_utf8_chunk(pending_utf8);
Some((content, offset + read as u64, pending_utf8))
}
async fn execution_log_execution_terminal(db: &sqlx::PgPool, execution_id: i64) -> bool {
match ExecutionRepository::find_by_id(db, execution_id).await {
Ok(Some(execution)) => matches!(
execution.status,
ExecutionStatus::Completed
| ExecutionStatus::Failed
| ExecutionStatus::Cancelled
| ExecutionStatus::Timeout
| ExecutionStatus::Abandoned
),
_ => true,
}
}
fn decode_utf8_chunk(mut bytes: Vec<u8>) -> (String, Vec<u8>) {
match std::str::from_utf8(&bytes) {
Ok(valid) => (valid.to_string(), Vec::new()),
Err(err) if err.error_len().is_none() => {
let pending = bytes.split_off(err.valid_up_to());
(String::from_utf8_lossy(&bytes).into_owned(), pending)
}
Err(_) => (String::from_utf8_lossy(&bytes).into_owned(), Vec::new()),
}
}
async fn authorize_execution_log_stream(
state: &Arc<AppState>,
user: &AuthenticatedUser,
execution: &attune_common::models::Execution,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(execution.id);
ctx.target_ref = Some(execution.action_ref.clone());
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Executions,
action: Action::Read,
context: ctx,
},
)
.await
}
fn authenticate_execution_log_stream_user(
state: &Arc<AppState>,
headers: &HeaderMap,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
query_token: Option<&str>,
) -> Result<AuthenticatedUser, ApiError> {
match user {
Ok(RequireAuth(user)) => Ok(user),
Err(_) => {
if let Some(user) = crate::auth::oidc::cookie_authenticated_user(headers, state)? {
return Ok(user);
}
let token = query_token.ok_or(ApiError::Unauthorized(
"Missing authentication token".to_string(),
))?;
authenticate_execution_log_stream_query_token(token, &state.jwt_config)
}
}
}
fn authenticate_execution_log_stream_query_token(
token: &str,
jwt_config: &JwtConfig,
) -> Result<AuthenticatedUser, ApiError> {
let claims = validate_token(token, jwt_config)
.map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
Ok(AuthenticatedUser { claims })
}
fn validate_execution_log_stream_user(
user: &AuthenticatedUser,
execution_id: i64,
) -> Result<(), ApiError> {
let claims = &user.claims;
match claims.token_type {
TokenType::Access => Ok(()),
TokenType::Execution => validate_execution_token_scope(claims, execution_id),
TokenType::Sensor | TokenType::Refresh => Err(ApiError::Unauthorized(
"Invalid authentication token".to_string(),
)),
}
}
fn validate_execution_token_scope(claims: &Claims, execution_id: i64) -> Result<(), ApiError> {
if claims.scope.as_deref() != Some("execution") {
return Err(ApiError::Unauthorized(
"Invalid authentication token".to_string(),
));
}
let token_execution_id = claims
.metadata
.as_ref()
.and_then(|metadata| metadata.get("execution_id"))
.and_then(|value| value.as_i64())
.ok_or_else(|| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
if token_execution_id != execution_id {
return Err(ApiError::Forbidden(format!(
"Execution token is not valid for execution {}",
execution_id
)));
}
Ok(())
}
#[derive(serde::Deserialize)]
pub struct StreamExecutionParams {
pub execution_id: Option<i64>,
@@ -937,6 +1337,10 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/executions/execute", axum::routing::post(create_execution))
.route("/executions/stats", get(get_execution_stats))
.route("/executions/stream", get(stream_execution_updates))
.route(
"/executions/{id}/logs/{stream}/stream",
get(stream_execution_log),
)
.route("/executions/{id}", get(get_execution))
.route(
"/executions/{id}/cancel",
@@ -955,10 +1359,26 @@ pub fn routes() -> Router<Arc<AppState>> {
#[cfg(test)]
mod tests {
use super::*;
use attune_common::auth::jwt::generate_execution_token;
#[test]
fn test_execution_routes_structure() {
// Just verify the router can be constructed
let _router = routes();
}
#[test]
fn execution_token_scope_must_match_requested_execution() {
let jwt_config = JwtConfig {
secret: "test_secret_key_for_testing".to_string(),
access_token_expiration: 3600,
refresh_token_expiration: 604800,
};
let token = generate_execution_token(42, 123, "core.echo", &jwt_config, None).unwrap();
let user = authenticate_execution_log_stream_query_token(&token, &jwt_config).unwrap();
let err = validate_execution_log_stream_user(&user, 456).unwrap_err();
assert!(matches!(err, ApiError::Forbidden(_)));
}
}

View File

@@ -23,6 +23,7 @@ clap = { workspace = true, features = ["derive", "env", "string"] }
# HTTP client
reqwest = { workspace = true, features = ["multipart", "stream"] }
reqwest-eventsource = { workspace = true }
# Serialization
serde = { workspace = true }

View File

@@ -21,6 +21,11 @@ pub struct ApiResponse<T> {
pub data: T,
}
#[derive(Debug, serde::Deserialize)]
struct PaginatedResponse<T> {
data: Vec<T>,
}
/// API error response
#[derive(Debug, serde::Deserialize)]
pub struct ApiError {
@@ -55,6 +60,10 @@ impl ApiClient {
&self.base_url
}
pub fn auth_token(&self) -> Option<&str> {
self.auth_token.as_deref()
}
#[cfg(test)]
pub fn new(base_url: String, auth_token: Option<String>) -> Self {
let client = HttpClient::builder()
@@ -255,6 +264,31 @@ impl ApiClient {
}
}
async fn handle_paginated_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> Result<Vec<T>> {
let status = response.status();
if status.is_success() {
let paginated: PaginatedResponse<T> = response
.json()
.await
.context("Failed to parse paginated API response")?;
Ok(paginated.data)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if let Ok(api_error) = serde_json::from_str::<ApiError>(&error_text) {
anyhow::bail!("API error ({}): {}", status, api_error.error);
} else {
anyhow::bail!("API error ({}): {}", status, error_text);
}
}
}
/// Handle a response where we only care about success/failure, not a body.
async fn handle_empty_response(&self, response: reqwest::Response) -> Result<()> {
let status = response.status();
@@ -281,6 +315,25 @@ impl ApiClient {
self.execute_json::<T, ()>(Method::GET, path, None).await
}
pub async fn get_paginated<T: DeserializeOwned>(&mut self, path: &str) -> Result<Vec<T>> {
let req = self.build_request(Method::GET, path);
let response = req.send().await.context("Failed to send request to API")?;
if response.status() == StatusCode::UNAUTHORIZED
&& self.refresh_token.is_some()
&& self.refresh_auth_token().await?
{
let req = self.build_request(Method::GET, path);
let response = req
.send()
.await
.context("Failed to send request to API (retry)")?;
return self.handle_paginated_response(response).await;
}
self.handle_paginated_response(response).await
}
/// GET request with query parameters (query string must be in path)
///
/// Part of REST client API - reserved for future advanced filtering/search features.

View File

@@ -6,7 +6,7 @@ use std::collections::HashMap;
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
use crate::wait::{wait_for_execution, WaitOptions};
use crate::wait::{extract_stdout, spawn_execution_output_watch, wait_for_execution, WaitOptions};
#[derive(Subcommand)]
pub enum ActionCommands {
@@ -493,6 +493,15 @@ async fn handle_execute(
}
let verbose = matches!(output_format, OutputFormat::Table);
let watch_task = if verbose {
Some(spawn_execution_output_watch(
ApiClient::from_config(&config, api_url),
execution.id,
verbose,
))
} else {
None
};
let summary = wait_for_execution(WaitOptions {
execution_id: execution.id,
timeout_secs: timeout,
@@ -501,6 +510,13 @@ async fn handle_execute(
verbose,
})
.await?;
let suppress_final_stdout = watch_task
.as_ref()
.is_some_and(|task| task.delivered_output() && task.root_stdout_completed());
if let Some(task) = watch_task {
let _ = tokio::time::timeout(tokio::time::Duration::from_secs(2), task.handle).await;
}
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
@@ -517,7 +533,20 @@ async fn handle_execute(
("Updated", output::format_timestamp(&summary.updated)),
]);
if let Some(result) = summary.result {
let stdout = extract_stdout(&summary.result);
if !suppress_final_stdout {
if let Some(stdout) = &stdout {
output::print_section("Stdout");
println!("{}", stdout);
}
}
if let Some(mut result) = summary.result {
if stdout.is_some() {
if let Some(obj) = result.as_object_mut() {
obj.remove("stdout");
}
}
if !result.is_null() {
output::print_section("Result");
println!("{}", serde_json::to_string_pretty(&result)?);

View File

@@ -803,6 +803,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- CLI users explicitly choose a local file to upload; this is not a server-side path sink.
let file_path = Path::new(&file);
if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
@@ -811,6 +812,7 @@ async fn handle_upload(
anyhow::bail!("Not a file: {}", file);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The validated CLI-selected upload path is intentionally read and sent to the API.
let file_bytes = tokio::fs::read(file_path).await?;
let file_name = file_path
.file_name()

View File

@@ -840,6 +840,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- CLI pack commands intentionally operate on operator-supplied local paths.
let pack_dir = Path::new(&path);
// Validate the directory exists and contains pack.yaml
@@ -855,6 +856,7 @@ async fn handle_upload(
}
// Read pack ref from pack.yaml so we can display it
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading local pack metadata from the user-selected pack directory is expected CLI behavior.
let pack_yaml_content =
std::fs::read_to_string(&pack_yaml_path).context("Failed to read pack.yaml")?;
let pack_yaml: serde_yaml_ng::Value =
@@ -957,6 +959,7 @@ fn append_dir_to_tar<W: std::io::Write>(
base: &Path,
dir: &Path,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The archiver walks a validated local directory selected by the CLI operator.
for entry in std::fs::read_dir(dir).context("Failed to read directory")? {
let entry = entry.context("Failed to read directory entry")?;
let entry_path = entry.path();
@@ -1061,6 +1064,7 @@ async fn handle_test(
use std::path::{Path, PathBuf};
// Determine if pack is a path or a pack name
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack test targets are local CLI inputs, not remote request paths.
let pack_path = Path::new(&pack);
let (pack_dir, pack_ref, pack_version) = if pack_path.exists() && pack_path.is_dir() {
// Local pack directory
@@ -1072,6 +1076,7 @@ async fn handle_test(
anyhow::bail!("pack.yaml not found in directory: {}", pack);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- This reads pack.yaml from a local directory explicitly selected by the CLI operator.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1107,6 +1112,7 @@ async fn handle_test(
anyhow::bail!("pack.yaml not found for pack: {}", pack);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Installed pack tests intentionally read local metadata from the workspace packs directory.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1120,6 +1126,7 @@ async fn handle_test(
// Load pack.yaml and extract test configuration
let pack_yaml_path = pack_dir.join("pack.yaml");
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Test configuration is loaded from the validated local pack directory.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1484,6 +1491,7 @@ fn detect_source_type(source: &str, ref_spec: Option<&str>, no_registry: bool) -
async fn handle_checksum(path: String, json: bool, output_format: OutputFormat) -> Result<()> {
use attune_common::pack_registry::{calculate_directory_checksum, calculate_file_checksum};
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Checksum generation intentionally accepts arbitrary local paths from the CLI operator.
let path_obj = Path::new(&path);
if !path_obj.exists() {
@@ -1581,6 +1589,7 @@ async fn handle_index_entry(
) -> Result<()> {
use attune_common::pack_registry::calculate_directory_checksum;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index-entry generation intentionally inspects a local pack directory chosen by the CLI operator.
let path_obj = Path::new(&path);
if !path_obj.exists() {
@@ -1606,6 +1615,7 @@ async fn handle_index_entry(
}
// Read and parse pack.yaml
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading local pack metadata for index generation is expected CLI behavior.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;

View File

@@ -19,11 +19,13 @@ pub async fn handle_index_update(
output_format: OutputFormat,
) -> Result<()> {
// Load existing index
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Registry index maintenance is a local CLI/admin operation over operator-supplied files.
let index_file_path = Path::new(&index_path);
if !index_file_path.exists() {
return Err(anyhow::anyhow!("Index file not found: {}", index_path));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The CLI intentionally reads the local index file selected by the operator.
let index_content = fs::read_to_string(index_file_path)?;
let mut index: JsonValue = serde_json::from_str(&index_content)?;
@@ -34,6 +36,7 @@ pub async fn handle_index_update(
.ok_or_else(|| anyhow::anyhow!("Invalid index format: missing 'packs' array"))?;
// Load pack.yaml from the pack directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Local pack directories are explicit CLI inputs, not remote taint.
let pack_dir = Path::new(&pack_path);
if !pack_dir.exists() || !pack_dir.is_dir() {
return Err(anyhow::anyhow!("Pack directory not found: {}", pack_path));
@@ -47,6 +50,7 @@ pub async fn handle_index_update(
));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading pack.yaml from a local operator-selected pack directory is expected CLI behavior.
let pack_yaml_content = fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -250,6 +254,7 @@ pub async fn handle_index_merge(
output_format: OutputFormat,
) -> Result<()> {
// Check if output file exists
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index merge output is a local CLI path controlled by the operator.
let output_file_path = Path::new(&output_path);
if output_file_path.exists() && !force {
return Err(anyhow::anyhow!(
@@ -265,6 +270,7 @@ pub async fn handle_index_merge(
// Load and merge all input files
for input_path in &input_paths {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index merge inputs are local operator-selected files.
let input_file_path = Path::new(input_path);
if !input_file_path.exists() {
if output_format == OutputFormat::Table {
@@ -277,6 +283,7 @@ pub async fn handle_index_merge(
output::print_info(&format!("Loading: {}", input_path));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The CLI intentionally reads each local input index file during merge.
let index_content = fs::read_to_string(input_file_path)?;
let index: JsonValue = serde_json::from_str(&index_content)?;

View File

@@ -172,6 +172,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow upload reads local files chosen by the CLI operator; it is not a server-side path sink.
let action_path = Path::new(&action_file);
// ── 1. Validate & read the action YAML ──────────────────────────────
@@ -182,6 +183,7 @@ async fn handle_upload(
anyhow::bail!("Path is not a file: {}", action_file);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The action YAML is intentionally read from the validated local CLI path.
let action_yaml_content =
std::fs::read_to_string(action_path).context("Failed to read action YAML file")?;
@@ -216,6 +218,7 @@ async fn handle_upload(
}
// ── 4. Read and parse the workflow YAML ─────────────────────────────
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The workflow file path is confined to the pack directory before this local read occurs.
let workflow_yaml_content =
std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?;
@@ -616,12 +619,41 @@ fn split_action_ref(action_ref: &str) -> Result<(String, String)> {
/// resolved relative to the action YAML's parent directory.
fn resolve_workflow_path(action_yaml_path: &Path, workflow_file: &str) -> Result<PathBuf> {
let action_dir = action_yaml_path.parent().unwrap_or(Path::new("."));
let pack_root = action_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Action YAML must live inside a pack actions/ directory"))?;
let canonical_pack_root = pack_root
.canonicalize()
.context("Failed to resolve pack root for workflow file")?;
let canonical_action_dir = action_dir
.canonicalize()
.context("Failed to resolve action directory for workflow file")?;
let canonical_workflow_path = normalize_path_from_base(&canonical_action_dir, workflow_file);
let resolved = action_dir.join(workflow_file);
if !canonical_workflow_path.starts_with(&canonical_pack_root) {
anyhow::bail!(
"Workflow file resolves outside the pack directory: {}",
workflow_file
);
}
// Canonicalize if possible (for better error messages), but don't fail
// if the file doesn't exist yet — we'll check existence later.
Ok(resolved)
Ok(canonical_workflow_path)
}
fn normalize_path_from_base(base: &Path, relative_path: &str) -> PathBuf {
let mut normalized = PathBuf::new();
for component in base.join(relative_path).components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
#[cfg(test)]
@@ -655,23 +687,62 @@ mod tests {
#[test]
fn test_resolve_workflow_path() {
let action_path = Path::new("/packs/mypack/actions/deploy.yaml");
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
let workflow_dir = actions_dir.join("workflows");
std::fs::create_dir_all(&workflow_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let workflow_path = workflow_dir.join("deploy.workflow.yaml");
std::fs::write(
&action_path,
"ref: mypack.deploy\nworkflow_file: workflows/deploy.workflow.yaml\n",
)
.unwrap();
std::fs::write(&workflow_path, "version: 1.0.0\n").unwrap();
let resolved =
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(
resolved,
PathBuf::from("/packs/mypack/actions/workflows/deploy.workflow.yaml")
);
resolve_workflow_path(&action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(resolved, workflow_path.canonicalize().unwrap());
}
#[test]
fn test_resolve_workflow_path_relative() {
let action_path = Path::new("actions/deploy.yaml");
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
let workflows_dir = pack_dir.join("workflows");
std::fs::create_dir_all(&actions_dir).unwrap();
std::fs::create_dir_all(&workflows_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let workflow_path = workflows_dir.join("deploy.workflow.yaml");
std::fs::write(
&action_path,
"ref: mypack.deploy\nworkflow_file: ../workflows/deploy.workflow.yaml\n",
)
.unwrap();
std::fs::write(&workflow_path, "version: 1.0.0\n").unwrap();
let resolved =
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(
resolved,
PathBuf::from("actions/workflows/deploy.workflow.yaml")
);
resolve_workflow_path(&action_path, "../workflows/deploy.workflow.yaml").unwrap();
assert_eq!(resolved, workflow_path.canonicalize().unwrap());
}
#[test]
fn test_resolve_workflow_path_rejects_traversal_outside_pack() {
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let outside = temp.path().join("outside.yaml");
std::fs::write(&action_path, "ref: mypack.deploy\n").unwrap();
std::fs::write(&outside, "version: 1.0.0\n").unwrap();
let err = resolve_workflow_path(&action_path, "../../outside.yaml").unwrap_err();
assert!(err.to_string().contains("outside the pack directory"));
}
}

View File

@@ -11,7 +11,13 @@
use anyhow::Result;
use futures::{SinkExt, StreamExt};
use reqwest_eventsource::{Event as SseEvent, EventSource};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
use std::time::{Duration, Instant};
use tokio_tungstenite::{connect_async, tungstenite::Message};
@@ -54,6 +60,22 @@ pub struct WaitOptions<'a> {
pub verbose: bool,
}
pub struct OutputWatchTask {
pub handle: tokio::task::JoinHandle<()>,
delivered_output: Arc<AtomicBool>,
root_stdout_completed: Arc<AtomicBool>,
}
impl OutputWatchTask {
pub fn delivered_output(&self) -> bool {
self.delivered_output.load(Ordering::Relaxed)
}
pub fn root_stdout_completed(&self) -> bool {
self.root_stdout_completed.load(Ordering::Relaxed)
}
}
// ── notifier WebSocket messages (mirrors websocket_server.rs) ────────────────
#[derive(Debug, Serialize)]
@@ -102,6 +124,58 @@ struct RestExecution {
updated: String,
}
#[derive(Debug, Clone, Deserialize)]
struct WorkflowTaskMetadata {
task_name: String,
#[serde(default)]
task_index: Option<i32>,
}
#[derive(Debug, Clone, Deserialize)]
struct ExecutionListItem {
id: i64,
action_ref: String,
status: String,
#[serde(default)]
workflow_task: Option<WorkflowTaskMetadata>,
}
#[derive(Debug)]
struct ChildWatchState {
label: String,
status: String,
announced_terminal: bool,
stream_handles: Vec<StreamWatchHandle>,
}
struct RootWatchState {
stream_handles: Vec<StreamWatchHandle>,
}
#[derive(Debug)]
struct StreamWatchHandle {
stream_name: &'static str,
offset: Arc<AtomicU64>,
handle: tokio::task::JoinHandle<()>,
}
#[derive(Clone)]
struct StreamWatchConfig {
base_url: String,
token: String,
execution_id: i64,
prefix: Option<String>,
verbose: bool,
delivered_output: Arc<AtomicBool>,
root_stdout_completed: Option<Arc<AtomicBool>>,
}
struct StreamLogTask {
stream_name: &'static str,
offset: Arc<AtomicU64>,
config: StreamWatchConfig,
}
impl From<RestExecution> for ExecutionSummary {
fn from(e: RestExecution) -> Self {
Self {
@@ -177,6 +251,260 @@ pub async fn wait_for_execution(opts: WaitOptions<'_>) -> Result<ExecutionSummar
.await
}
pub fn spawn_execution_output_watch(
mut client: ApiClient,
execution_id: i64,
verbose: bool,
) -> OutputWatchTask {
let delivered_output = Arc::new(AtomicBool::new(false));
let root_stdout_completed = Arc::new(AtomicBool::new(false));
let delivered_output_for_task = delivered_output.clone();
let root_stdout_completed_for_task = root_stdout_completed.clone();
let handle = tokio::spawn(async move {
if let Err(err) = watch_execution_output(
&mut client,
execution_id,
verbose,
delivered_output_for_task,
root_stdout_completed_for_task,
)
.await
{
if verbose {
eprintln!(" [watch] {}", err);
}
}
});
OutputWatchTask {
handle,
delivered_output,
root_stdout_completed,
}
}
async fn watch_execution_output(
client: &mut ApiClient,
execution_id: i64,
verbose: bool,
delivered_output: Arc<AtomicBool>,
root_stdout_completed: Arc<AtomicBool>,
) -> Result<()> {
let base_url = client.base_url().to_string();
let mut root_watch: Option<RootWatchState> = None;
let mut children: HashMap<i64, ChildWatchState> = HashMap::new();
loop {
let execution: RestExecution = client.get(&format!("/executions/{}", execution_id)).await?;
if root_watch
.as_ref()
.is_none_or(|state| streams_need_restart(&state.stream_handles))
{
if let Some(token) = client.auth_token().map(str::to_string) {
match root_watch.as_mut() {
Some(state) => restart_finished_streams(
&mut state.stream_handles,
&StreamWatchConfig {
base_url: base_url.clone(),
token,
execution_id,
prefix: None,
verbose,
delivered_output: delivered_output.clone(),
root_stdout_completed: Some(root_stdout_completed.clone()),
},
),
None => {
root_watch = Some(RootWatchState {
stream_handles: spawn_execution_log_streams(StreamWatchConfig {
base_url: base_url.clone(),
token,
execution_id,
verbose,
prefix: None,
delivered_output: delivered_output.clone(),
root_stdout_completed: Some(root_stdout_completed.clone()),
}),
});
}
}
}
}
let child_items = list_child_executions(client, execution_id)
.await
.unwrap_or_default();
for child in child_items {
let label = format_task_label(&child.workflow_task, &child.action_ref, child.id);
let entry = children.entry(child.id).or_insert_with(|| {
if verbose {
eprintln!(" [{}] started ({})", label, child.action_ref);
}
let stream_handles = client
.auth_token()
.map(str::to_string)
.map(|token| {
spawn_execution_log_streams(StreamWatchConfig {
base_url: base_url.clone(),
token,
execution_id: child.id,
prefix: Some(label.clone()),
verbose,
delivered_output: delivered_output.clone(),
root_stdout_completed: None,
})
})
.unwrap_or_default();
ChildWatchState {
label,
status: child.status.clone(),
announced_terminal: false,
stream_handles,
}
});
if entry.status != child.status {
entry.status = child.status.clone();
}
let child_is_terminal = is_terminal(&entry.status);
if !child_is_terminal && streams_need_restart(&entry.stream_handles) {
if let Some(token) = client.auth_token().map(str::to_string) {
restart_finished_streams(
&mut entry.stream_handles,
&StreamWatchConfig {
base_url: base_url.clone(),
token,
execution_id: child.id,
prefix: Some(entry.label.clone()),
verbose,
delivered_output: delivered_output.clone(),
root_stdout_completed: None,
},
);
}
}
if !entry.announced_terminal && is_terminal(&child.status) {
entry.announced_terminal = true;
if verbose {
eprintln!(" [{}] {}", entry.label, child.status);
}
}
}
if is_terminal(&execution.status) {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
if let Some(root_watch) = root_watch {
wait_for_stream_handles(root_watch.stream_handles).await;
}
for child in children.into_values() {
wait_for_stream_handles(child.stream_handles).await;
}
Ok(())
}
fn spawn_execution_log_streams(config: StreamWatchConfig) -> Vec<StreamWatchHandle> {
["stdout", "stderr"]
.into_iter()
.map(|stream_name| {
let offset = Arc::new(AtomicU64::new(0));
let completion_flag = if stream_name == "stdout" {
config.root_stdout_completed.clone()
} else {
None
};
StreamWatchHandle {
stream_name,
handle: tokio::spawn(stream_execution_log(StreamLogTask {
stream_name,
offset: offset.clone(),
config: StreamWatchConfig {
base_url: config.base_url.clone(),
token: config.token.clone(),
execution_id: config.execution_id,
prefix: config.prefix.clone(),
verbose: config.verbose,
delivered_output: config.delivered_output.clone(),
root_stdout_completed: completion_flag,
},
})),
offset,
}
})
.collect()
}
fn streams_need_restart(handles: &[StreamWatchHandle]) -> bool {
handles.is_empty() || handles.iter().any(|handle| handle.handle.is_finished())
}
fn restart_finished_streams(handles: &mut [StreamWatchHandle], config: &StreamWatchConfig) {
for stream in handles.iter_mut() {
if stream.handle.is_finished() {
let offset = stream.offset.clone();
let completion_flag = if stream.stream_name == "stdout" {
config.root_stdout_completed.clone()
} else {
None
};
stream.handle = tokio::spawn(stream_execution_log(StreamLogTask {
stream_name: stream.stream_name,
offset,
config: StreamWatchConfig {
base_url: config.base_url.clone(),
token: config.token.clone(),
execution_id: config.execution_id,
prefix: config.prefix.clone(),
verbose: config.verbose,
delivered_output: config.delivered_output.clone(),
root_stdout_completed: completion_flag,
},
}));
}
}
}
async fn wait_for_stream_handles(handles: Vec<StreamWatchHandle>) {
for handle in handles {
let _ = handle.handle.await;
}
}
async fn list_child_executions(
client: &mut ApiClient,
execution_id: i64,
) -> Result<Vec<ExecutionListItem>> {
const PER_PAGE: u32 = 100;
let mut page = 1;
let mut all_children = Vec::new();
loop {
let path = format!("/executions?parent={execution_id}&page={page}&per_page={PER_PAGE}");
let mut page_items: Vec<ExecutionListItem> = client.get_paginated(&path).await?;
let page_len = page_items.len();
all_children.append(&mut page_items);
if page_len < PER_PAGE as usize {
break;
}
page += 1;
}
Ok(all_children)
}
// ── WebSocket path ────────────────────────────────────────────────────────────
async fn wait_via_websocket(
@@ -482,6 +810,7 @@ fn resolve_ws_url(opts: &WaitOptions<'_>) -> Option<String> {
/// - `https://api.example.com` → `wss://api.example.com:8081`
/// - `http://api.example.com:9000` → `ws://api.example.com:8081`
fn derive_notifier_url(api_url: &str) -> Option<String> {
// nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- The function upgrades https->wss and only returns ws for explicit http base URLs or test examples.
let url = url::Url::parse(api_url).ok()?;
let ws_scheme = match url.scheme() {
"https" => "wss",
@@ -491,6 +820,148 @@ fn derive_notifier_url(api_url: &str) -> Option<String> {
Some(format!("{}://{}:8081", ws_scheme, host))
}
pub fn extract_stdout(result: &Option<serde_json::Value>) -> Option<String> {
result
.as_ref()
.and_then(|value| value.get("stdout"))
.and_then(|stdout| stdout.as_str())
.filter(|stdout| !stdout.is_empty())
.map(ToOwned::to_owned)
}
fn format_task_label(
workflow_task: &Option<WorkflowTaskMetadata>,
action_ref: &str,
execution_id: i64,
) -> String {
if let Some(workflow_task) = workflow_task {
if let Some(index) = workflow_task.task_index {
format!("{}[{}]", workflow_task.task_name, index)
} else {
workflow_task.task_name.clone()
}
} else {
format!("{}#{}", action_ref, execution_id)
}
}
async fn stream_execution_log(task: StreamLogTask) {
let StreamLogTask {
stream_name,
offset,
config:
StreamWatchConfig {
base_url,
token,
execution_id,
prefix,
verbose,
delivered_output,
root_stdout_completed,
},
} = task;
let mut stream_url = match url::Url::parse(&format!(
"{}/api/v1/executions/{}/logs/{}/stream",
base_url.trim_end_matches('/'),
execution_id,
stream_name
)) {
Ok(url) => url,
Err(err) => {
if verbose {
eprintln!(" [watch] failed to build stream URL: {}", err);
}
return;
}
};
let current_offset = offset.load(Ordering::Relaxed).to_string();
stream_url
.query_pairs_mut()
.append_pair("token", &token)
.append_pair("offset", &current_offset);
let mut event_source = EventSource::get(stream_url);
let mut carry = String::new();
while let Some(event) = event_source.next().await {
match event {
Ok(SseEvent::Open) => {}
Ok(SseEvent::Message(message)) => match message.event.as_str() {
"content" | "append" => {
if let Ok(server_offset) = message.id.parse::<u64>() {
offset.store(server_offset, Ordering::Relaxed);
}
if !message.data.is_empty() {
delivered_output.store(true, Ordering::Relaxed);
}
print_stream_chunk(prefix.as_deref(), &message.data, &mut carry);
}
"done" => {
if let Some(flag) = &root_stdout_completed {
flag.store(true, Ordering::Relaxed);
}
flush_stream_chunk(prefix.as_deref(), &mut carry);
break;
}
"error" => {
if verbose && !message.data.is_empty() {
eprintln!(" [watch] {}", message.data);
}
break;
}
_ => {}
},
Err(err) => {
flush_stream_chunk(prefix.as_deref(), &mut carry);
if verbose {
eprintln!(
" [watch] stream error for execution {}: {}",
execution_id, err
);
}
break;
}
}
}
flush_stream_chunk(prefix.as_deref(), &mut carry);
event_source.close();
}
fn print_stream_chunk(prefix: Option<&str>, chunk: &str, carry: &mut String) {
carry.push_str(chunk);
while let Some(idx) = carry.find('\n') {
let mut line = carry.drain(..=idx).collect::<String>();
if line.ends_with('\n') {
line.pop();
}
if line.ends_with('\r') {
line.pop();
}
if let Some(prefix) = prefix {
eprintln!("[{}] {}", prefix, line);
} else {
eprintln!("{}", line);
}
}
}
fn flush_stream_chunk(prefix: Option<&str>, carry: &mut String) {
if carry.is_empty() {
return;
}
if let Some(prefix) = prefix {
eprintln!("[{}] {}", prefix, carry);
} else {
eprintln!("{}", carry);
}
carry.clear();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -553,4 +1024,26 @@ mod tests {
assert_eq!(summary.status, "failed");
assert_eq!(summary.action_ref, "");
}
#[test]
fn test_extract_stdout() {
let result = Some(serde_json::json!({
"stdout": "hello world",
"stderr_log": "/tmp/stderr.log"
}));
assert_eq!(extract_stdout(&result).as_deref(), Some("hello world"));
}
#[test]
fn test_format_task_label() {
let workflow_task = Some(WorkflowTaskMetadata {
task_name: "build".to_string(),
task_index: Some(2),
});
assert_eq!(
format_task_label(&workflow_task, "core.echo", 42),
"build[2]"
);
assert_eq!(format_task_label(&None, "core.echo", 42), "core.echo#42");
}
}

View File

@@ -73,6 +73,7 @@ regex = { workspace = true }
# Version matching
semver = { workspace = true }
url = { workspace = true }
[dev-dependencies]
mockall = { workspace = true }

View File

@@ -658,6 +658,11 @@ pub struct PackRegistryConfig {
#[serde(default = "default_true")]
pub verify_checksums: bool,
/// Additional remote hosts allowed for pack archive/git downloads.
/// Hosts from enabled registry indices are implicitly allowed.
#[serde(default)]
pub allowed_source_hosts: Vec<String>,
/// Allow HTTP (non-HTTPS) registries
#[serde(default)]
pub allow_http: bool,
@@ -680,6 +685,7 @@ impl Default for PackRegistryConfig {
cache_enabled: true,
timeout: default_registry_timeout(),
verify_checksums: true,
allowed_source_hosts: Vec::new(),
allow_http: false,
}
}
@@ -1029,14 +1035,12 @@ impl Config {
}
if let Some(ldap) = &self.security.ldap {
if ldap.enabled {
if ldap.url.as_deref().unwrap_or("").trim().is_empty() {
if ldap.enabled && ldap.url.as_deref().unwrap_or("").trim().is_empty() {
return Err(crate::Error::validation(
"LDAP server URL is required when LDAP is enabled",
));
}
}
}
// Validate encryption key if provided
if let Some(ref key) = self.security.encryption_key {

View File

@@ -1412,7 +1412,7 @@ pub mod artifact {
pub content_type: Option<String>,
/// Size of the latest version's content in bytes
pub size_bytes: Option<i64>,
/// Execution that produced this artifact (no FK — execution is a hypertable)
/// Execution that produced this artifact (no FK by design)
pub execution: Option<Id>,
/// Structured JSONB data for progress artifacts or metadata
pub data: Option<serde_json::Value>,

View File

@@ -102,7 +102,12 @@ impl MqError {
pub fn is_retriable(&self) -> bool {
matches!(
self,
MqError::Connection(_) | MqError::Channel(_) | MqError::Timeout(_) | MqError::Pool(_)
MqError::Connection(_)
| MqError::Channel(_)
| MqError::Publish(_)
| MqError::Timeout(_)
| MqError::Pool(_)
| MqError::Lapin(_)
)
}

View File

@@ -12,6 +12,7 @@ use crate::models::Runtime;
use crate::repositories::action::ActionRepository;
use crate::repositories::runtime::{self, RuntimeRepository};
use crate::repositories::FindById as _;
use regex::Regex;
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row};
use std::collections::{HashMap, HashSet};
@@ -94,10 +95,7 @@ pub struct PackEnvironmentManager {
impl PackEnvironmentManager {
/// Create a new pack environment manager
pub fn new(pool: PgPool, config: &Config) -> Self {
let base_path = PathBuf::from(&config.packs_base_dir)
.parent()
.map(|p| p.join("packenvs"))
.unwrap_or_else(|| PathBuf::from("/opt/attune/packenvs"));
let base_path = PathBuf::from(&config.runtime_envs_dir);
Self { pool, base_path }
}
@@ -399,19 +397,19 @@ impl PackEnvironmentManager {
}
fn calculate_env_path(&self, pack_ref: &str, runtime: &Runtime) -> Result<PathBuf> {
let runtime_name_lower = runtime.name.to_lowercase();
let template = runtime
.installers
.get("base_path_template")
.and_then(|v| v.as_str())
.unwrap_or("/opt/attune/packenvs/{pack_ref}/{runtime_name_lower}");
.unwrap_or("{pack_ref}/{runtime_name_lower}");
let runtime_name_lower = runtime.name.to_lowercase();
let path_str = template
.replace("{pack_ref}", pack_ref)
.replace("{runtime_ref}", &runtime.r#ref)
.replace("{runtime_name_lower}", &runtime_name_lower);
Ok(PathBuf::from(path_str))
resolve_env_path(&self.base_path, &path_str)
}
async fn upsert_environment_record(
@@ -528,6 +526,7 @@ impl PackEnvironmentManager {
let mut install_log = String::new();
// Create environment directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- env_path comes from validated runtime-env path construction under runtime_envs_dir.
let env_path = PathBuf::from(&pack_env.env_path);
if env_path.exists() {
warn!(
@@ -659,6 +658,8 @@ impl PackEnvironmentManager {
env_path,
&pack_path_str,
)?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The candidate command path is validated and confined before any execution is attempted.
let command = validate_installer_command(&command, pack_path, Path::new(env_path))?;
let args_template = installer
.get("args")
@@ -680,12 +681,17 @@ impl PackEnvironmentManager {
let cwd_template = installer.get("cwd").and_then(|v| v.as_str());
let cwd = if let Some(cwd_t) = cwd_template {
Some(self.resolve_template(
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Installer cwd values are validated to stay under the pack root or environment directory.
Some(validate_installer_path(
&self.resolve_template(
cwd_t,
pack_ref,
runtime_ref,
env_path,
&pack_path_str,
)?,
pack_path,
Path::new(env_path),
)?)
} else {
None
@@ -763,6 +769,7 @@ impl PackEnvironmentManager {
async fn execute_installer_action(&self, action: &InstallerAction) -> Result<String> {
debug!("Executing: {} {:?}", action.command, action.args);
// nosemgrep: rust.actix.command-injection.rust-actix-command-injection.rust-actix-command-injection -- action.command is accepted only after strict validation of executable shape and allowed path roots.
let mut cmd = Command::new(&action.command);
cmd.args(&action.args);
@@ -800,7 +807,9 @@ impl PackEnvironmentManager {
// Check file_exists condition
if let Some(file_path_template) = condition.get("file_exists").and_then(|v| v.as_str()) {
let file_path = file_path_template.replace("{pack_path}", &pack_path.to_string_lossy());
return Ok(PathBuf::from(file_path).exists());
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Conditional file checks are validated to stay under trusted pack/environment roots before filesystem access.
let validated = validate_installer_path(&file_path, pack_path, &self.base_path)?;
return Ok(PathBuf::from(validated).exists());
}
// Default: condition is true
@@ -816,6 +825,93 @@ impl PackEnvironmentManager {
}
}
fn resolve_env_path(base_path: &Path, path_str: &str) -> Result<PathBuf> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- This helper normalizes env paths and preserves legacy absolute templates while still rejecting parent traversal.
let raw_path = Path::new(path_str);
if raw_path.is_absolute() {
return normalize_relative_or_absolute_path(raw_path);
}
let joined = base_path.join(raw_path);
normalize_relative_or_absolute_path(&joined)
}
fn normalize_relative_or_absolute_path(path: &Path) -> Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
return Err(Error::validation(format!(
"Parent-directory traversal is not allowed in installer paths: {}",
path.display()
)));
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
Ok(normalized)
}
fn validate_installer_command(command: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Command validation inspects the path form before enforcing allowed executable rules.
let command_path = Path::new(command);
if command_path.is_absolute() {
return validate_installer_path(command, pack_path, env_path);
}
if command.contains(std::path::MAIN_SEPARATOR) {
return Err(Error::validation(format!(
"Installer command must be a bare executable name or an allowed absolute path: {}",
command
)));
}
let command_name_re = Regex::new(r"^[A-Za-z0-9._+-]+$").expect("valid installer regex");
if !command_name_re.is_match(command) {
return Err(Error::validation(format!(
"Installer command contains invalid characters: {}",
command
)));
}
Ok(command.to_string())
}
fn validate_installer_path(path_str: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Path validation normalizes candidate installer paths before enforcing root confinement.
let path = normalize_path(Path::new(path_str));
let normalized_pack_path = normalize_path(pack_path);
let normalized_env_path = normalize_path(env_path);
if path.starts_with(&normalized_pack_path) || path.starts_with(&normalized_env_path) {
Ok(path.to_string_lossy().to_string())
} else {
Err(Error::validation(format!(
"Installer path must remain under the pack or environment directory: {}",
path_str
)))
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
/// Collect the lowercase runtime names that require environment setup for a pack.
///
/// This queries the pack's actions, resolves their runtimes, and returns the names

View File

@@ -349,6 +349,7 @@ mod tests {
cache_enabled: true,
timeout: 120,
verify_checksums: true,
allowed_source_hosts: Vec::new(),
allow_http: false,
};

View File

@@ -11,10 +11,14 @@
use super::{Checksum, InstallSource, PackIndexEntry, RegistryClient};
use crate::config::PackRegistryConfig;
use crate::error::{Error, Result};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::net::lookup_host;
use tokio::process::Command;
use url::Url;
/// Progress callback type
pub type ProgressCallback = Arc<dyn Fn(ProgressEvent) + Send + Sync>;
@@ -53,6 +57,12 @@ pub struct PackInstaller {
/// Whether to verify checksums
verify_checksums: bool,
/// Whether HTTP remote sources are allowed
allow_http: bool,
/// Remote hosts allowed for archive/git installs
allowed_remote_hosts: Option<HashSet<String>>,
/// Progress callback (optional)
progress_callback: Option<ProgressCallback>,
}
@@ -106,17 +116,32 @@ impl PackInstaller {
.await
.map_err(|e| Error::internal(format!("Failed to create temp directory: {}", e)))?;
let (registry_client, verify_checksums) = if let Some(config) = registry_config {
let (registry_client, verify_checksums, allow_http, allowed_remote_hosts) =
if let Some(config) = registry_config {
let verify_checksums = config.verify_checksums;
(Some(RegistryClient::new(config)?), verify_checksums)
let allow_http = config.allow_http;
let allowed_remote_hosts = collect_allowed_remote_hosts(&config)?;
let allowed_remote_hosts = if allowed_remote_hosts.is_empty() {
None
} else {
(None, false)
Some(allowed_remote_hosts)
};
(
Some(RegistryClient::new(config)?),
verify_checksums,
allow_http,
allowed_remote_hosts,
)
} else {
(None, false, false, None)
};
Ok(Self {
temp_dir,
registry_client,
verify_checksums,
allow_http,
allowed_remote_hosts,
progress_callback: None,
})
}
@@ -152,6 +177,7 @@ impl PackInstaller {
/// Install from git repository
async fn install_from_git(&self, url: &str, git_ref: Option<&str>) -> Result<InstalledPack> {
self.validate_git_source(url).await?;
tracing::info!("Installing pack from git: {} (ref: {:?})", url, git_ref);
self.report_progress(ProgressEvent::StepStarted {
@@ -405,10 +431,12 @@ impl PackInstaller {
/// Download an archive from a URL
async fn download_archive(&self, url: &str) -> Result<PathBuf> {
let parsed_url = self.validate_remote_url(url).await?;
let client = reqwest::Client::new();
// nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- Remote source URLs are restricted to configured allowlisted hosts, HTTPS, and public IPs before request execution.
let response = client
.get(url)
.get(parsed_url.clone())
.send()
.await
.map_err(|e| Error::internal(format!("Failed to download archive: {}", e)))?;
@@ -421,11 +449,7 @@ impl PackInstaller {
}
// Determine filename from URL
let filename = url
.split('/')
.next_back()
.unwrap_or("archive.zip")
.to_string();
let filename = archive_filename_from_url(&parsed_url);
let archive_path = self.temp_dir.join(&filename);
@@ -442,6 +466,116 @@ impl PackInstaller {
Ok(archive_path)
}
async fn validate_remote_url(&self, raw_url: &str) -> Result<Url> {
let parsed = Url::parse(raw_url)
.map_err(|e| Error::validation(format!("Invalid remote URL '{}': {}", raw_url, e)))?;
if parsed.scheme() != "https" && !(self.allow_http && parsed.scheme() == "http") {
return Err(Error::validation(format!(
"Remote URL must use https{}: {}",
if self.allow_http {
" or http when pack_registry.allow_http is enabled"
} else {
""
},
raw_url
)));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(Error::validation(
"Remote URLs with embedded credentials are not allowed".to_string(),
));
}
let host = parsed.host_str().ok_or_else(|| {
Error::validation(format!("Remote URL is missing a host: {}", raw_url))
})?;
let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" {
return Err(Error::validation(format!(
"Remote URL host is not allowed: {}",
host
)));
}
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
if !allowed_remote_hosts.contains(&normalized_host) {
return Err(Error::validation(format!(
"Remote URL host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
host
)));
}
}
if let Some(ip) = parsed.host().and_then(|host| match host {
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
url::Host::Domain(_) => None,
}) {
ensure_public_ip(ip)?;
}
let port = parsed.port_or_known_default().ok_or_else(|| {
Error::validation(format!("Remote URL is missing a usable port: {}", raw_url))
})?;
let resolved = lookup_host((host, port))
.await
.map_err(|e| Error::validation(format!("Failed to resolve host '{}': {}", host, e)))?;
let mut saw_address = false;
for addr in resolved {
saw_address = true;
ensure_public_ip(addr.ip())?;
}
if !saw_address {
return Err(Error::validation(format!(
"Remote URL host did not resolve to any addresses: {}",
host
)));
}
Ok(parsed)
}
async fn validate_git_source(&self, raw_url: &str) -> Result<()> {
if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
self.validate_remote_url(raw_url).await?;
return Ok(());
}
if let Some(host) = extract_git_host(raw_url) {
self.validate_remote_host(&host)?;
}
Ok(())
}
fn validate_remote_host(&self, host: &str) -> Result<()> {
let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" {
return Err(Error::validation(format!(
"Remote host is not allowed: {}",
host
)));
}
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
if !allowed_remote_hosts.contains(&normalized_host) {
return Err(Error::validation(format!(
"Remote host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
host
)));
}
}
Ok(())
}
/// Extract an archive (zip or tar.gz)
async fn extract_archive(&self, archive_path: &Path) -> Result<PathBuf> {
let extract_dir = self.create_temp_dir().await?;
@@ -583,6 +717,7 @@ impl PackInstaller {
}
// Check in first subdirectory (common for GitHub archives)
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Archive inspection is limited to the temporary extraction directory created by this installer.
let mut entries = fs::read_dir(base_dir)
.await
.map_err(|e| Error::internal(format!("Failed to read directory: {}", e)))?;
@@ -618,6 +753,7 @@ impl PackInstaller {
})?;
// Read source directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Directory copy operates on installer-managed local paths, not request-derived paths.
let mut entries = fs::read_dir(src)
.await
.map_err(|e| Error::internal(format!("Failed to read source directory: {}", e)))?;
@@ -674,6 +810,111 @@ impl PackInstaller {
}
}
fn collect_allowed_remote_hosts(config: &PackRegistryConfig) -> Result<HashSet<String>> {
let mut hosts = HashSet::new();
for index in &config.indices {
if !index.enabled {
continue;
}
let parsed = Url::parse(&index.url).map_err(|e| {
Error::validation(format!("Invalid registry index URL '{}': {}", index.url, e))
})?;
let host = parsed.host_str().ok_or_else(|| {
Error::validation(format!(
"Registry index URL '{}' is missing a host",
index.url
))
})?;
hosts.insert(host.to_ascii_lowercase());
}
for host in &config.allowed_source_hosts {
let normalized = host.trim().to_ascii_lowercase();
if !normalized.is_empty() {
hosts.insert(normalized);
}
}
Ok(hosts)
}
fn extract_git_host(raw_url: &str) -> Option<String> {
if let Ok(parsed) = Url::parse(raw_url) {
return parsed.host_str().map(|host| host.to_ascii_lowercase());
}
raw_url.split_once('@').and_then(|(_, rest)| {
rest.split_once(':')
.map(|(host, _)| host.to_ascii_lowercase())
})
}
fn archive_filename_from_url(url: &Url) -> String {
let raw_name = url
.path_segments()
.and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
.unwrap_or("archive.bin");
let sanitized: String = raw_name
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch,
_ => '_',
})
.collect();
let filename = sanitized.trim_matches('.');
if filename.is_empty() {
"archive.bin".to_string()
} else {
filename.to_string()
}
}
fn ensure_public_ip(ip: IpAddr) -> Result<()> {
let is_blocked = match ip {
IpAddr::V4(ip) => {
let octets = ip.octets();
let is_documentation_range = matches!(
octets,
[192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
);
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| is_documentation_range
|| ip.is_unspecified()
|| octets[0] == 0
}
IpAddr::V6(ip) => {
let segments = ip.segments();
let is_documentation_range = segments[0] == 0x2001 && segments[1] == 0x0db8;
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| is_documentation_range
|| ip == Ipv6Addr::LOCALHOST
}
};
if is_blocked {
return Err(Error::validation(format!(
"Remote URL resolved to a non-public address: {}",
ip
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -721,4 +962,52 @@ mod tests {
assert!(matches!(source, InstallSource::Git { .. }));
}
#[test]
fn test_archive_filename_from_url_sanitizes_path_segments() {
let url = Url::parse("https://example.com/releases/../../pack.zip?token=x").unwrap();
assert_eq!(archive_filename_from_url(&url), "pack.zip");
}
#[test]
fn test_ensure_public_ip_rejects_private_ipv4() {
let err = ensure_public_ip(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))).unwrap_err();
assert!(err.to_string().contains("non-public"));
}
#[test]
fn test_collect_allowed_remote_hosts_includes_indices_and_overrides() {
let config = PackRegistryConfig {
indices: vec![crate::config::RegistryIndexConfig {
url: "https://registry.example.com/index.json".to_string(),
priority: 1,
enabled: true,
name: None,
headers: std::collections::HashMap::new(),
}],
allowed_source_hosts: vec!["github.com".to_string(), "cdn.example.com".to_string()],
..Default::default()
};
let hosts = collect_allowed_remote_hosts(&config).unwrap();
assert!(hosts.contains("registry.example.com"));
assert!(hosts.contains("github.com"));
assert!(hosts.contains("cdn.example.com"));
}
#[test]
fn test_extract_git_host_from_scp_style_source() {
assert_eq!(
extract_git_host("git@github.com:org/repo.git"),
Some("github.com".to_string())
);
}
#[test]
fn test_extract_git_host_from_git_scheme_source() {
assert_eq!(
extract_git_host("git://github.com/org/repo.git"),
Some("github.com".to_string())
);
}
}

View File

@@ -31,7 +31,7 @@
//! can reference the same workflow file with different configurations.
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use sqlx::PgPool;
use tracing::{debug, info, warn};
@@ -1091,7 +1091,10 @@ impl<'a> PackComponentLoader<'a> {
action_description: &str,
action_data: &serde_yaml_ng::Value,
) -> Result<Id> {
let full_path = actions_dir.join(workflow_file_path);
let pack_root = actions_dir.parent().ok_or_else(|| {
Error::validation("Actions directory must live inside a pack directory".to_string())
})?;
let full_path = resolve_pack_relative_path(pack_root, actions_dir, workflow_file_path)?;
if !full_path.exists() {
return Err(Error::validation(format!(
"Workflow file '{}' not found at '{}'",
@@ -1100,6 +1103,7 @@ impl<'a> PackComponentLoader<'a> {
)));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The workflow path is normalized and confined to the pack root before this local read.
let content = std::fs::read_to_string(&full_path).map_err(|e| {
Error::io(format!(
"Failed to read workflow file '{}': {}",
@@ -1649,11 +1653,60 @@ impl<'a> PackComponentLoader<'a> {
}
}
fn resolve_pack_relative_path(
pack_root: &Path,
base_dir: &Path,
relative_path: &str,
) -> Result<PathBuf> {
let canonical_pack_root = pack_root.canonicalize().map_err(|e| {
Error::io(format!(
"Failed to resolve pack root '{}': {}",
pack_root.display(),
e
))
})?;
let canonical_base_dir = base_dir.canonicalize().map_err(|e| {
Error::io(format!(
"Failed to resolve base directory '{}': {}",
base_dir.display(),
e
))
})?;
let canonical_candidate = normalize_path_from_base(&canonical_base_dir, relative_path);
if !canonical_candidate.starts_with(&canonical_pack_root) {
return Err(Error::validation(format!(
"Resolved path '{}' escapes pack root '{}'",
canonical_candidate.display(),
canonical_pack_root.display()
)));
}
Ok(canonical_candidate)
}
fn normalize_path_from_base(base: &Path, relative_path: &str) -> PathBuf {
let mut normalized = PathBuf::new();
for component in base.join(relative_path).components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
/// Read all YAML files from a directory, returning `(filename, content)` pairs
/// sorted by filename for deterministic ordering.
fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack loader scans pack-owned directories on disk after selecting the pack root.
let entries = std::fs::read_dir(dir)
.map_err(|e| Error::io(format!("Failed to read directory {}: {}", dir.display(), e)))?;
@@ -1676,6 +1729,7 @@ fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
let path = entry.path();
let filename = entry.file_name().to_string_lossy().to_string();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- YAML files are read only after being discovered under the selected pack directory.
let content = std::fs::read_to_string(&path)
.map_err(|e| Error::io(format!("Failed to read file {}: {}", path.display(), e)))?;

View File

@@ -292,6 +292,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
))
})?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack storage copy recursively processes validated local directories under the configured pack store.
for entry in fs::read_dir(src).map_err(|e| {
Error::io(format!(
"Failed to read source directory {}: {}",

View File

@@ -571,7 +571,7 @@ impl Repository for PolicyRepository {
type Entity = Policy;
fn table_name() -> &'static str {
"policies"
"policy"
}
}
@@ -612,7 +612,7 @@ impl FindById for PolicyRepository {
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policies
FROM policy
WHERE id = $1
"#,
)
@@ -634,7 +634,7 @@ impl FindByRef for PolicyRepository {
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policies
FROM policy
WHERE ref = $1
"#,
)
@@ -656,7 +656,7 @@ impl List for PolicyRepository {
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policies
FROM policy
ORDER BY ref ASC
"#,
)
@@ -678,7 +678,7 @@ impl Create for PolicyRepository {
// Try to insert - database will enforce uniqueness constraint
let policy = sqlx::query_as::<_, Policy>(
r#"
INSERT INTO policies (ref, pack, pack_ref, action, action_ref, parameters,
INSERT INTO policy (ref, pack, pack_ref, action, action_ref, parameters,
method, threshold, name, description, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, ref, pack, pack_ref, action, action_ref, parameters, method,
@@ -720,7 +720,7 @@ impl Update for PolicyRepository {
where
E: Executor<'e, Database = Postgres> + 'e,
{
let mut query = QueryBuilder::new("UPDATE policies SET ");
let mut query = QueryBuilder::new("UPDATE policy SET ");
let mut has_updates = false;
if let Some(parameters) = &input.parameters {
@@ -798,7 +798,7 @@ impl Delete for PolicyRepository {
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM policies WHERE id = $1")
let result = sqlx::query("DELETE FROM policy WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
@@ -817,7 +817,7 @@ impl PolicyRepository {
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policies
FROM policy
WHERE action = $1
ORDER BY ref ASC
"#,
@@ -838,7 +838,7 @@ impl PolicyRepository {
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policies
FROM policy
WHERE $1 = ANY(tags)
ORDER BY ref ASC
"#,
@@ -849,4 +849,69 @@ impl PolicyRepository {
Ok(policies)
}
/// Find the most recent action-specific policy.
pub async fn find_latest_by_action<'e, E>(executor: E, action_id: Id) -> Result<Option<Policy>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let policy = sqlx::query_as::<_, Policy>(
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policy
WHERE action = $1
ORDER BY created DESC
LIMIT 1
"#,
)
.bind(action_id)
.fetch_optional(executor)
.await?;
Ok(policy)
}
/// Find the most recent pack-specific policy.
pub async fn find_latest_by_pack<'e, E>(executor: E, pack_id: Id) -> Result<Option<Policy>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let policy = sqlx::query_as::<_, Policy>(
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policy
WHERE pack = $1 AND action IS NULL
ORDER BY created DESC
LIMIT 1
"#,
)
.bind(pack_id)
.fetch_optional(executor)
.await?;
Ok(policy)
}
/// Find the most recent global policy.
pub async fn find_latest_global<'e, E>(executor: E) -> Result<Option<Policy>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let policy = sqlx::query_as::<_, Policy>(
r#"
SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method,
threshold, name, description, tags, created, updated
FROM policy
WHERE pack IS NULL AND action IS NULL
ORDER BY created DESC
LIMIT 1
"#,
)
.fetch_optional(executor)
.await?;
Ok(policy)
}
}

View File

@@ -80,7 +80,7 @@ pub struct EnforcementVolumeBucket {
pub enforcement_count: i64,
}
/// A single hourly bucket of execution volume (from execution hypertable directly).
/// A single hourly bucket of execution volume (from the execution table directly).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ExecutionVolumeBucket {
/// Start of the 1-hour bucket
@@ -468,7 +468,7 @@ impl AnalyticsRepository {
}
// =======================================================================
// Execution volume (from execution hypertable directly)
// Execution volume (from the execution table directly)
// =======================================================================
/// Query the `execution_volume_hourly` continuous aggregate for execution

View File

@@ -65,6 +65,12 @@ pub struct EnforcementSearchResult {
pub total: u64,
}
#[derive(Debug, Clone)]
pub struct EnforcementCreateOrGetResult {
pub enforcement: Enforcement,
pub created: bool,
}
/// Repository for Event operations
pub struct EventRepository;
@@ -416,7 +422,115 @@ impl Update for EnforcementRepository {
where
E: Executor<'e, Database = Postgres> + 'e,
{
// Build update query
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Self::get_by_id(executor, id).await;
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ");
query.push_bind(id);
})
.await
}
}
#[async_trait::async_trait]
impl Delete for EnforcementRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM enforcement WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl EnforcementRepository {
async fn update_with_locator<'e, E, F>(
executor: E,
input: UpdateEnforcementInput,
where_clause: F,
) -> Result<Enforcement>
where
E: Executor<'e, Database = Postgres> + 'e,
F: FnOnce(&mut QueryBuilder<'_, Postgres>),
{
let mut query = QueryBuilder::new("UPDATE enforcement SET ");
let mut has_updates = false;
if let Some(status) = input.status {
query.push("status = ");
query.push_bind(status);
has_updates = true;
}
if let Some(payload) = &input.payload {
if has_updates {
query.push(", ");
}
query.push("payload = ");
query.push_bind(payload);
has_updates = true;
}
if let Some(resolved_at) = input.resolved_at {
if has_updates {
query.push(", ");
}
query.push("resolved_at = ");
query.push_bind(resolved_at);
}
where_clause(&mut query);
query.push(
" RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload, \
condition, conditions, created, resolved_at",
);
let enforcement = query
.build_query_as::<Enforcement>()
.fetch_one(executor)
.await?;
Ok(enforcement)
}
/// Update an enforcement using the loaded row's primary key.
pub async fn update_loaded<'e, E>(
executor: E,
enforcement: &Enforcement,
input: UpdateEnforcementInput,
) -> Result<Enforcement>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Ok(enforcement.clone());
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ");
query.push_bind(enforcement.id);
})
.await
}
pub async fn update_loaded_if_status<'e, E>(
executor: E,
enforcement: &Enforcement,
expected_status: EnforcementStatus,
input: UpdateEnforcementInput,
) -> Result<Option<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Ok(Some(enforcement.clone()));
}
let mut query = QueryBuilder::new("UPDATE enforcement SET ");
let mut has_updates = false;
@@ -446,39 +560,25 @@ impl Update for EnforcementRepository {
}
if !has_updates {
// No updates requested, fetch and return existing entity
return Self::get_by_id(executor, id).await;
return Ok(Some(enforcement.clone()));
}
query.push(" WHERE id = ");
query.push_bind(id);
query.push(" RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload, condition, conditions, created, resolved_at");
query.push_bind(enforcement.id);
query.push(" AND status = ");
query.push_bind(expected_status);
query.push(
" RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload, \
condition, conditions, created, resolved_at",
);
let enforcement = query
query
.build_query_as::<Enforcement>()
.fetch_one(executor)
.await?;
Ok(enforcement)
}
.fetch_optional(executor)
.await
.map_err(Into::into)
}
#[async_trait::async_trait]
impl Delete for EnforcementRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM enforcement WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl EnforcementRepository {
/// Find enforcements by rule ID
pub async fn find_by_rule<'e, E>(executor: E, rule_id: Id) -> Result<Vec<Enforcement>>
where
@@ -545,6 +645,90 @@ impl EnforcementRepository {
Ok(enforcements)
}
pub async fn find_by_rule_and_event<'e, E>(
executor: E,
rule_id: Id,
event_id: Id,
) -> Result<Option<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE rule = $1 AND event = $2
LIMIT 1
"#,
)
.bind(rule_id)
.bind(event_id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn create_or_get_by_rule_event<'e, E>(
executor: E,
input: CreateEnforcementInput,
) -> Result<EnforcementCreateOrGetResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let (Some(rule_id), Some(event_id)) = (input.rule, input.event) else {
let enforcement = Self::create(executor, input).await?;
return Ok(EnforcementCreateOrGetResult {
enforcement,
created: true,
});
};
let inserted = sqlx::query_as::<_, Enforcement>(
r#"
INSERT INTO enforcement (rule, rule_ref, trigger_ref, config, event, status,
payload, condition, conditions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (rule, event) WHERE rule IS NOT NULL AND event IS NOT NULL DO NOTHING
RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
"#,
)
.bind(input.rule)
.bind(&input.rule_ref)
.bind(&input.trigger_ref)
.bind(&input.config)
.bind(input.event)
.bind(input.status)
.bind(&input.payload)
.bind(input.condition)
.bind(&input.conditions)
.fetch_optional(executor)
.await?;
if let Some(enforcement) = inserted {
return Ok(EnforcementCreateOrGetResult {
enforcement,
created: true,
});
}
let enforcement = Self::find_by_rule_and_event(executor, rule_id, event_id)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"enforcement for rule {} and event {} disappeared after dedupe conflict",
rule_id,
event_id
)
})?;
Ok(EnforcementCreateOrGetResult {
enforcement,
created: false,
})
}
/// Search enforcements with all filters pushed into SQL.
///
/// All filter fields are combinable (AND). Pagination is server-side.

View File

@@ -4,7 +4,8 @@ use chrono::{DateTime, Utc};
use crate::models::{enums::ExecutionStatus, execution::*, Id, JsonDict};
use crate::Result;
use sqlx::{Executor, Postgres, QueryBuilder};
use sqlx::{Executor, PgConnection, PgPool, Postgres, QueryBuilder};
use tokio::time::{sleep, Duration};
use super::{Create, Delete, FindById, List, Repository, Update};
@@ -41,6 +42,18 @@ pub struct ExecutionSearchResult {
pub total: u64,
}
#[derive(Debug, Clone)]
pub struct WorkflowTaskExecutionCreateOrGetResult {
pub execution: Execution,
pub created: bool,
}
#[derive(Debug, Clone)]
pub struct EnforcementExecutionCreateOrGetResult {
pub execution: Execution,
pub created: bool,
}
/// An execution row with optional `rule_ref` / `trigger_ref` populated from
/// the joined `enforcement` table. This avoids a separate in-memory lookup.
#[derive(Debug, Clone, sqlx::FromRow)]
@@ -191,7 +204,577 @@ impl Update for ExecutionRepository {
where
E: Executor<'e, Database = Postgres> + 'e,
{
// Build update query
if input.status.is_none()
&& input.result.is_none()
&& input.executor.is_none()
&& input.worker.is_none()
&& input.started_at.is_none()
&& input.workflow_task.is_none()
{
return Self::get_by_id(executor, id).await;
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ").push_bind(id);
})
.await
}
}
impl ExecutionRepository {
pub async fn find_top_level_by_enforcement<'e, E>(
executor: E,
enforcement_id: Id,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let sql = format!(
"SELECT {SELECT_COLUMNS} \
FROM execution \
WHERE enforcement = $1
AND parent IS NULL
AND (config IS NULL OR NOT (config ? 'retry_of')) \
ORDER BY created ASC \
LIMIT 1"
);
sqlx::query_as::<_, Execution>(&sql)
.bind(enforcement_id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn create_top_level_for_enforcement_if_absent<'e, E>(
executor: E,
input: CreateExecutionInput,
enforcement_id: Id,
) -> Result<EnforcementExecutionCreateOrGetResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let inserted = sqlx::query_as::<_, Execution>(&format!(
"INSERT INTO execution \
(action, action_ref, config, env_vars, parent, enforcement, executor, worker, status, result, workflow_task) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
ON CONFLICT (enforcement)
WHERE enforcement IS NOT NULL
AND parent IS NULL
AND (config IS NULL OR NOT (config ? 'retry_of'))
DO NOTHING \
RETURNING {SELECT_COLUMNS}"
))
.bind(input.action)
.bind(&input.action_ref)
.bind(&input.config)
.bind(&input.env_vars)
.bind(input.parent)
.bind(input.enforcement)
.bind(input.executor)
.bind(input.worker)
.bind(input.status)
.bind(&input.result)
.bind(sqlx::types::Json(&input.workflow_task))
.fetch_optional(executor)
.await?;
if let Some(execution) = inserted {
return Ok(EnforcementExecutionCreateOrGetResult {
execution,
created: true,
});
}
let execution = Self::find_top_level_by_enforcement(executor, enforcement_id)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"top-level execution for enforcement {} disappeared after dedupe conflict",
enforcement_id
)
})?;
Ok(EnforcementExecutionCreateOrGetResult {
execution,
created: false,
})
}
async fn claim_workflow_task_dispatch<'e, E>(
executor: E,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let inserted: Option<(i64,)> = sqlx::query_as(
"INSERT INTO workflow_task_dispatch (workflow_execution, task_name, task_index)
VALUES ($1, $2, $3)
ON CONFLICT (workflow_execution, task_name, COALESCE(task_index, -1)) DO NOTHING
RETURNING id",
)
.bind(workflow_execution_id)
.bind(task_name)
.bind(task_index)
.fetch_optional(executor)
.await?;
Ok(inserted.is_some())
}
async fn assign_workflow_task_dispatch_execution<'e, E>(
executor: E,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
execution_id: Id,
) -> Result<()>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query(
"UPDATE workflow_task_dispatch
SET execution_id = COALESCE(execution_id, $4)
WHERE workflow_execution = $1
AND task_name = $2
AND task_index IS NOT DISTINCT FROM $3",
)
.bind(workflow_execution_id)
.bind(task_name)
.bind(task_index)
.bind(execution_id)
.execute(executor)
.await?;
Ok(())
}
async fn lock_workflow_task_dispatch<'e, E>(
executor: E,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<Option<Option<Id>>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let row: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT execution_id
FROM workflow_task_dispatch
WHERE workflow_execution = $1
AND task_name = $2
AND task_index IS NOT DISTINCT FROM $3
FOR UPDATE",
)
.bind(workflow_execution_id)
.bind(task_name)
.bind(task_index)
.fetch_optional(executor)
.await?;
// Map the outer Option to distinguish three cases:
// - None → no row exists
// - Some(None) → row exists but execution_id is still NULL (mid-creation)
// - Some(Some(id)) → row exists with a completed execution_id
Ok(row.map(|(execution_id,)| execution_id))
}
async fn create_workflow_task_if_absent_in_conn(
conn: &mut PgConnection,
input: CreateExecutionInput,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<WorkflowTaskExecutionCreateOrGetResult> {
let claimed = Self::claim_workflow_task_dispatch(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
)
.await?;
if claimed {
let execution = Self::create(&mut *conn, input).await?;
Self::assign_workflow_task_dispatch_execution(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
execution.id,
)
.await?;
return Ok(WorkflowTaskExecutionCreateOrGetResult {
execution,
created: true,
});
}
let dispatch_state = Self::lock_workflow_task_dispatch(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
)
.await?;
match dispatch_state {
Some(Some(existing_execution_id)) => {
// Row exists with execution_id — return the existing execution.
let execution = Self::find_by_id(&mut *conn, existing_execution_id)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"workflow child execution {} missing for workflow_execution {} task '{}' index {:?}",
existing_execution_id,
workflow_execution_id,
task_name,
task_index
)
})?;
Ok(WorkflowTaskExecutionCreateOrGetResult {
execution,
created: false,
})
}
Some(None) => {
// Row exists but execution_id is still NULL: another transaction is
// mid-creation (between claim and assign). Retry until it's filled in.
// If the original creator's transaction rolled back, the row also
// disappears — handled by the `None` branch inside the loop.
'wait: {
for _ in 0..20_u32 {
sleep(Duration::from_millis(50)).await;
match Self::lock_workflow_task_dispatch(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
)
.await?
{
Some(Some(execution_id)) => {
let execution =
Self::find_by_id(&mut *conn, execution_id).await?.ok_or_else(
|| {
anyhow::anyhow!(
"workflow child execution {} missing for workflow_execution {} task '{}' index {:?}",
execution_id,
workflow_execution_id,
task_name,
task_index
)
},
)?;
return Ok(WorkflowTaskExecutionCreateOrGetResult {
execution,
created: false,
});
}
Some(None) => {} // still NULL, keep waiting
None => break 'wait, // row rolled back; fall through to re-claim
}
}
// Exhausted all retries without the execution_id being set.
return Err(anyhow::anyhow!(
"Timed out waiting for workflow task dispatch execution_id to be set \
for workflow_execution {} task '{}' index {:?}",
workflow_execution_id,
task_name,
task_index
)
.into());
}
// Row disappeared (original creator rolled back) — re-claim and create.
let re_claimed = Self::claim_workflow_task_dispatch(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
)
.await?;
if !re_claimed {
return Err(anyhow::anyhow!(
"Workflow task dispatch for workflow_execution {} task '{}' index {:?} \
was reclaimed by another executor after rollback",
workflow_execution_id,
task_name,
task_index
)
.into());
}
let execution = Self::create(&mut *conn, input).await?;
Self::assign_workflow_task_dispatch_execution(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
execution.id,
)
.await?;
Ok(WorkflowTaskExecutionCreateOrGetResult {
execution,
created: true,
})
}
None => {
// No row at all — the original INSERT was rolled back before we arrived.
// Attempt to re-claim and create as if this were a fresh dispatch.
let re_claimed = Self::claim_workflow_task_dispatch(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
)
.await?;
if !re_claimed {
return Err(anyhow::anyhow!(
"Workflow task dispatch for workflow_execution {} task '{}' index {:?} \
was claimed by another executor",
workflow_execution_id,
task_name,
task_index
)
.into());
}
let execution = Self::create(&mut *conn, input).await?;
Self::assign_workflow_task_dispatch_execution(
&mut *conn,
workflow_execution_id,
task_name,
task_index,
execution.id,
)
.await?;
Ok(WorkflowTaskExecutionCreateOrGetResult {
execution,
created: true,
})
}
}
}
pub async fn create_workflow_task_if_absent(
pool: &PgPool,
input: CreateExecutionInput,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<WorkflowTaskExecutionCreateOrGetResult> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN").execute(&mut *conn).await?;
let result = Self::create_workflow_task_if_absent_in_conn(
&mut conn,
input,
workflow_execution_id,
task_name,
task_index,
)
.await;
match result {
Ok(result) => {
sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(result)
}
Err(err) => {
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
Err(err)
}
}
}
pub async fn create_workflow_task_if_absent_with_conn(
conn: &mut PgConnection,
input: CreateExecutionInput,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<WorkflowTaskExecutionCreateOrGetResult> {
Self::create_workflow_task_if_absent_in_conn(
conn,
input,
workflow_execution_id,
task_name,
task_index,
)
.await
}
pub async fn claim_for_scheduling<'e, E>(
executor: E,
id: Id,
claiming_executor: Option<Id>,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let sql = format!(
"UPDATE execution \
SET status = $2, executor = COALESCE($3, executor), updated = NOW() \
WHERE id = $1 AND status = $4 \
RETURNING {SELECT_COLUMNS}"
);
sqlx::query_as::<_, Execution>(&sql)
.bind(id)
.bind(ExecutionStatus::Scheduling)
.bind(claiming_executor)
.bind(ExecutionStatus::Requested)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn reclaim_stale_scheduling<'e, E>(
executor: E,
id: Id,
claiming_executor: Option<Id>,
stale_before: DateTime<Utc>,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let sql = format!(
"UPDATE execution \
SET executor = COALESCE($2, executor), updated = NOW() \
WHERE id = $1 AND status = $3 AND updated <= $4 \
RETURNING {SELECT_COLUMNS}"
);
sqlx::query_as::<_, Execution>(&sql)
.bind(id)
.bind(claiming_executor)
.bind(ExecutionStatus::Scheduling)
.bind(stale_before)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn update_if_status<'e, E>(
executor: E,
id: Id,
expected_status: ExecutionStatus,
input: UpdateExecutionInput,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none()
&& input.result.is_none()
&& input.executor.is_none()
&& input.worker.is_none()
&& input.started_at.is_none()
&& input.workflow_task.is_none()
{
return Self::find_by_id(executor, id).await;
}
Self::update_with_locator_optional(executor, input, |query| {
query.push(" WHERE id = ").push_bind(id);
query.push(" AND status = ").push_bind(expected_status);
})
.await
}
pub async fn update_if_status_and_updated_before<'e, E>(
executor: E,
id: Id,
expected_status: ExecutionStatus,
stale_before: DateTime<Utc>,
input: UpdateExecutionInput,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none()
&& input.result.is_none()
&& input.executor.is_none()
&& input.worker.is_none()
&& input.started_at.is_none()
&& input.workflow_task.is_none()
{
return Self::find_by_id(executor, id).await;
}
Self::update_with_locator_optional(executor, input, |query| {
query.push(" WHERE id = ").push_bind(id);
query.push(" AND status = ").push_bind(expected_status);
query.push(" AND updated < ").push_bind(stale_before);
})
.await
}
pub async fn update_if_status_and_updated_at<'e, E>(
executor: E,
id: Id,
expected_status: ExecutionStatus,
expected_updated: DateTime<Utc>,
input: UpdateExecutionInput,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none()
&& input.result.is_none()
&& input.executor.is_none()
&& input.worker.is_none()
&& input.started_at.is_none()
&& input.workflow_task.is_none()
{
return Self::find_by_id(executor, id).await;
}
Self::update_with_locator_optional(executor, input, |query| {
query.push(" WHERE id = ").push_bind(id);
query.push(" AND status = ").push_bind(expected_status);
query.push(" AND updated = ").push_bind(expected_updated);
})
.await
}
pub async fn revert_scheduled_to_requested<'e, E>(
executor: E,
id: Id,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let sql = format!(
"UPDATE execution \
SET status = $2, worker = NULL, executor = NULL, updated = NOW() \
WHERE id = $1 AND status = $3 \
RETURNING {SELECT_COLUMNS}"
);
sqlx::query_as::<_, Execution>(&sql)
.bind(id)
.bind(ExecutionStatus::Requested)
.bind(ExecutionStatus::Scheduled)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
async fn update_with_locator<'e, E, F>(
executor: E,
input: UpdateExecutionInput,
where_clause: F,
) -> Result<Execution>
where
E: Executor<'e, Database = Postgres> + 'e,
F: FnOnce(&mut QueryBuilder<'_, Postgres>),
{
let mut query = QueryBuilder::new("UPDATE execution SET ");
let mut has_updates = false;
@@ -234,15 +817,10 @@ impl Update for ExecutionRepository {
query
.push("workflow_task = ")
.push_bind(sqlx::types::Json(workflow_task));
has_updates = true;
}
if !has_updates {
// No updates requested, fetch and return existing entity
return Self::get_by_id(executor, id).await;
}
query.push(", updated = NOW() WHERE id = ").push_bind(id);
query.push(", updated = NOW()");
where_clause(&mut query);
query.push(" RETURNING ");
query.push(SELECT_COLUMNS);
@@ -252,6 +830,96 @@ impl Update for ExecutionRepository {
.await
.map_err(Into::into)
}
async fn update_with_locator_optional<'e, E, F>(
executor: E,
input: UpdateExecutionInput,
where_clause: F,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
F: FnOnce(&mut QueryBuilder<'_, Postgres>),
{
let mut query = QueryBuilder::new("UPDATE execution SET ");
let mut has_updates = false;
if let Some(status) = input.status {
query.push("status = ").push_bind(status);
has_updates = true;
}
if let Some(result) = &input.result {
if has_updates {
query.push(", ");
}
query.push("result = ").push_bind(result);
has_updates = true;
}
if let Some(executor_id) = input.executor {
if has_updates {
query.push(", ");
}
query.push("executor = ").push_bind(executor_id);
has_updates = true;
}
if let Some(worker_id) = input.worker {
if has_updates {
query.push(", ");
}
query.push("worker = ").push_bind(worker_id);
has_updates = true;
}
if let Some(started_at) = input.started_at {
if has_updates {
query.push(", ");
}
query.push("started_at = ").push_bind(started_at);
has_updates = true;
}
if let Some(workflow_task) = &input.workflow_task {
if has_updates {
query.push(", ");
}
query
.push("workflow_task = ")
.push_bind(sqlx::types::Json(workflow_task));
}
query.push(", updated = NOW()");
where_clause(&mut query);
query.push(" RETURNING ");
query.push(SELECT_COLUMNS);
query
.build_query_as::<Execution>()
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Update an execution using the loaded row's primary key.
pub async fn update_loaded<'e, E>(
executor: E,
execution: &Execution,
input: UpdateExecutionInput,
) -> Result<Execution>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none()
&& input.result.is_none()
&& input.executor.is_none()
&& input.worker.is_none()
&& input.started_at.is_none()
&& input.workflow_task.is_none()
{
return Ok(execution.clone());
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ").push_bind(execution.id);
})
.await
}
}
#[async_trait::async_trait]
@@ -303,6 +971,34 @@ impl ExecutionRepository {
.map_err(Into::into)
}
pub async fn find_by_workflow_task<'e, E>(
executor: E,
workflow_execution_id: Id,
task_name: &str,
task_index: Option<i32>,
) -> Result<Option<Execution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let sql = format!(
"SELECT {SELECT_COLUMNS} \
FROM execution \
WHERE workflow_task->>'workflow_execution' = $1::text \
AND workflow_task->>'task_name' = $2 \
AND (workflow_task->>'task_index')::int IS NOT DISTINCT FROM $3 \
ORDER BY created ASC \
LIMIT 1"
);
sqlx::query_as::<_, Execution>(&sql)
.bind(workflow_execution_id.to_string())
.bind(task_name)
.bind(task_index)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Find all child executions for a given parent execution ID.
///
/// Returns child executions ordered by creation time (ascending),

View File

@@ -0,0 +1,909 @@
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Row, Transaction};
use crate::error::Result;
use crate::models::Id;
use crate::repositories::queue_stats::{QueueStatsRepository, UpsertQueueStatsInput};
#[derive(Debug, Clone)]
pub struct AdmissionSlotAcquireOutcome {
pub acquired: bool,
pub current_count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmissionEnqueueOutcome {
Acquired,
Enqueued,
}
#[derive(Debug, Clone)]
pub struct AdmissionSlotReleaseOutcome {
pub action_id: Id,
pub group_key: Option<String>,
pub next_execution_id: Option<Id>,
}
#[derive(Debug, Clone)]
pub struct AdmissionQueuedRemovalOutcome {
pub action_id: Id,
pub group_key: Option<String>,
pub next_execution_id: Option<Id>,
pub execution_id: Id,
pub queue_order: i64,
pub enqueued_at: DateTime<Utc>,
pub removed_index: usize,
}
#[derive(Debug, Clone)]
pub struct AdmissionQueueStats {
pub action_id: Id,
pub queue_length: usize,
pub active_count: u32,
pub max_concurrent: u32,
pub oldest_enqueued_at: Option<DateTime<Utc>>,
pub total_enqueued: u64,
pub total_completed: u64,
}
#[derive(Debug, Clone)]
struct AdmissionState {
id: Id,
action_id: Id,
group_key: Option<String>,
max_concurrent: i32,
}
#[derive(Debug, Clone)]
struct ExecutionEntry {
state_id: Id,
action_id: Id,
group_key: Option<String>,
status: String,
queue_order: i64,
enqueued_at: DateTime<Utc>,
}
pub struct ExecutionAdmissionRepository;
impl ExecutionAdmissionRepository {
pub async fn enqueue(
pool: &PgPool,
max_queue_length: usize,
action_id: Id,
execution_id: Id,
max_concurrent: u32,
group_key: Option<String>,
) -> Result<AdmissionEnqueueOutcome> {
let mut tx = pool.begin().await?;
let state = Self::lock_state(&mut tx, action_id, group_key, max_concurrent).await?;
let outcome =
Self::enqueue_in_state(&mut tx, &state, max_queue_length, execution_id, true).await?;
Self::refresh_queue_stats(&mut tx, action_id).await?;
tx.commit().await?;
Ok(outcome)
}
pub async fn wait_status(pool: &PgPool, execution_id: Id) -> Result<Option<bool>> {
let row = sqlx::query_scalar::<Postgres, bool>(
r#"
SELECT status = 'active'
FROM execution_admission_entry
WHERE execution_id = $1
"#,
)
.bind(execution_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn try_acquire(
pool: &PgPool,
action_id: Id,
execution_id: Id,
max_concurrent: u32,
group_key: Option<String>,
) -> Result<AdmissionSlotAcquireOutcome> {
let mut tx = pool.begin().await?;
let state = Self::lock_state(&mut tx, action_id, group_key, max_concurrent).await?;
let active_count = Self::active_count(&mut tx, state.id).await? as u32;
let outcome = match Self::find_execution_entry(&mut tx, execution_id).await? {
Some(entry) if entry.status == "active" => AdmissionSlotAcquireOutcome {
acquired: true,
current_count: active_count,
},
Some(entry) if entry.status == "queued" && entry.state_id == state.id => {
let promoted =
Self::maybe_promote_existing_queued(&mut tx, &state, execution_id).await?;
AdmissionSlotAcquireOutcome {
acquired: promoted,
current_count: active_count,
}
}
Some(_) => AdmissionSlotAcquireOutcome {
acquired: false,
current_count: active_count,
},
None => {
if active_count < max_concurrent
&& Self::queued_count(&mut tx, state.id).await? == 0
{
let queue_order = Self::allocate_queue_order(&mut tx, state.id).await?;
Self::insert_entry(
&mut tx,
state.id,
execution_id,
"active",
queue_order,
Utc::now(),
)
.await?;
Self::increment_total_enqueued(&mut tx, state.id).await?;
Self::refresh_queue_stats(&mut tx, action_id).await?;
AdmissionSlotAcquireOutcome {
acquired: true,
current_count: active_count,
}
} else {
AdmissionSlotAcquireOutcome {
acquired: false,
current_count: active_count,
}
}
}
};
tx.commit().await?;
Ok(outcome)
}
pub async fn release_active_slot(
pool: &PgPool,
execution_id: Id,
) -> Result<Option<AdmissionSlotReleaseOutcome>> {
let mut tx = pool.begin().await?;
let Some(entry) = Self::find_execution_entry_for_update(&mut tx, execution_id).await?
else {
tx.commit().await?;
return Ok(None);
};
if entry.status != "active" {
tx.commit().await?;
return Ok(None);
}
let state = Self::lock_existing_state(&mut tx, entry.action_id, entry.group_key.clone())
.await?
.ok_or_else(|| {
crate::Error::internal("missing execution_admission_state for active execution")
})?;
sqlx::query("DELETE FROM execution_admission_entry WHERE execution_id = $1")
.bind(execution_id)
.execute(&mut *tx)
.await?;
Self::increment_total_completed(&mut tx, state.id).await?;
let next_execution_id = Self::promote_next_queued(&mut tx, &state).await?;
Self::refresh_queue_stats(&mut tx, state.action_id).await?;
tx.commit().await?;
Ok(Some(AdmissionSlotReleaseOutcome {
action_id: state.action_id,
group_key: state.group_key,
next_execution_id,
}))
}
pub async fn restore_active_slot(
pool: &PgPool,
execution_id: Id,
outcome: &AdmissionSlotReleaseOutcome,
) -> Result<()> {
let mut tx = pool.begin().await?;
let state =
Self::lock_existing_state(&mut tx, outcome.action_id, outcome.group_key.clone())
.await?
.ok_or_else(|| {
crate::Error::internal("missing execution_admission_state on restore")
})?;
if let Some(next_execution_id) = outcome.next_execution_id {
sqlx::query(
r#"
UPDATE execution_admission_entry
SET status = 'queued', activated_at = NULL
WHERE execution_id = $1
AND state_id = $2
AND status = 'active'
"#,
)
.bind(next_execution_id)
.bind(state.id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
r#"
INSERT INTO execution_admission_entry (
state_id, execution_id, status, queue_order, enqueued_at, activated_at
) VALUES ($1, $2, 'active', $3, NOW(), NOW())
ON CONFLICT (execution_id) DO UPDATE
SET state_id = EXCLUDED.state_id,
status = 'active',
activated_at = EXCLUDED.activated_at
"#,
)
.bind(state.id)
.bind(execution_id)
.bind(Self::allocate_queue_order(&mut tx, state.id).await?)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
UPDATE execution_admission_state
SET total_completed = GREATEST(total_completed - 1, 0)
WHERE id = $1
"#,
)
.bind(state.id)
.execute(&mut *tx)
.await?;
Self::refresh_queue_stats(&mut tx, state.action_id).await?;
tx.commit().await?;
Ok(())
}
pub async fn remove_queued_execution(
pool: &PgPool,
execution_id: Id,
) -> Result<Option<AdmissionQueuedRemovalOutcome>> {
let mut tx = pool.begin().await?;
let Some(entry) = Self::find_execution_entry_for_update(&mut tx, execution_id).await?
else {
tx.commit().await?;
return Ok(None);
};
if entry.status != "queued" {
tx.commit().await?;
return Ok(None);
}
let state = Self::lock_existing_state(&mut tx, entry.action_id, entry.group_key.clone())
.await?
.ok_or_else(|| {
crate::Error::internal("missing execution_admission_state for queued execution")
})?;
let removed_index = sqlx::query_scalar::<Postgres, i64>(
r#"
SELECT COUNT(*)
FROM execution_admission_entry
WHERE state_id = $1
AND status = 'queued'
AND (enqueued_at, id) < (
SELECT enqueued_at, id
FROM execution_admission_entry
WHERE execution_id = $2
)
"#,
)
.bind(state.id)
.bind(execution_id)
.fetch_one(&mut *tx)
.await? as usize;
sqlx::query("DELETE FROM execution_admission_entry WHERE execution_id = $1")
.bind(execution_id)
.execute(&mut *tx)
.await?;
let next_execution_id =
if Self::active_count(&mut tx, state.id).await? < state.max_concurrent as i64 {
Self::promote_next_queued(&mut tx, &state).await?
} else {
None
};
Self::refresh_queue_stats(&mut tx, state.action_id).await?;
tx.commit().await?;
Ok(Some(AdmissionQueuedRemovalOutcome {
action_id: state.action_id,
group_key: state.group_key,
next_execution_id,
execution_id,
queue_order: entry.queue_order,
enqueued_at: entry.enqueued_at,
removed_index,
}))
}
pub async fn restore_queued_execution(
pool: &PgPool,
outcome: &AdmissionQueuedRemovalOutcome,
) -> Result<()> {
let mut tx = pool.begin().await?;
let state =
Self::lock_existing_state(&mut tx, outcome.action_id, outcome.group_key.clone())
.await?
.ok_or_else(|| {
crate::Error::internal("missing execution_admission_state on queued restore")
})?;
if let Some(next_execution_id) = outcome.next_execution_id {
sqlx::query(
r#"
UPDATE execution_admission_entry
SET status = 'queued', activated_at = NULL
WHERE execution_id = $1
AND state_id = $2
AND status = 'active'
"#,
)
.bind(next_execution_id)
.bind(state.id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
r#"
INSERT INTO execution_admission_entry (
state_id, execution_id, status, queue_order, enqueued_at, activated_at
) VALUES ($1, $2, 'queued', $3, $4, NULL)
ON CONFLICT (execution_id) DO NOTHING
"#,
)
.bind(state.id)
.bind(outcome.execution_id)
.bind(outcome.queue_order)
.bind(outcome.enqueued_at)
.execute(&mut *tx)
.await?;
Self::refresh_queue_stats(&mut tx, state.action_id).await?;
tx.commit().await?;
Ok(())
}
pub async fn get_queue_stats(
pool: &PgPool,
action_id: Id,
) -> Result<Option<AdmissionQueueStats>> {
let row = sqlx::query(
r#"
WITH state_rows AS (
SELECT
COUNT(*) AS state_count,
COALESCE(SUM(max_concurrent), 0) AS max_concurrent,
COALESCE(SUM(total_enqueued), 0) AS total_enqueued,
COALESCE(SUM(total_completed), 0) AS total_completed
FROM execution_admission_state
WHERE action_id = $1
),
entry_rows AS (
SELECT
COUNT(*) FILTER (WHERE e.status = 'queued') AS queue_length,
COUNT(*) FILTER (WHERE e.status = 'active') AS active_count,
MIN(e.enqueued_at) FILTER (WHERE e.status = 'queued') AS oldest_enqueued_at
FROM execution_admission_state s
LEFT JOIN execution_admission_entry e ON e.state_id = s.id
WHERE s.action_id = $1
)
SELECT
sr.state_count,
er.queue_length,
er.active_count,
sr.max_concurrent,
er.oldest_enqueued_at,
sr.total_enqueued,
sr.total_completed
FROM state_rows sr
CROSS JOIN entry_rows er
"#,
)
.bind(action_id)
.fetch_one(pool)
.await?;
let state_count: i64 = row.try_get("state_count")?;
if state_count == 0 {
return Ok(None);
}
Ok(Some(AdmissionQueueStats {
action_id,
queue_length: row.try_get::<i64, _>("queue_length")? as usize,
active_count: row.try_get::<i64, _>("active_count")? as u32,
max_concurrent: row.try_get::<i64, _>("max_concurrent")? as u32,
oldest_enqueued_at: row.try_get("oldest_enqueued_at")?,
total_enqueued: row.try_get::<i64, _>("total_enqueued")? as u64,
total_completed: row.try_get::<i64, _>("total_completed")? as u64,
}))
}
async fn enqueue_in_state(
tx: &mut Transaction<'_, Postgres>,
state: &AdmissionState,
max_queue_length: usize,
execution_id: Id,
allow_queue: bool,
) -> Result<AdmissionEnqueueOutcome> {
if let Some(entry) = Self::find_execution_entry(tx, execution_id).await? {
if entry.status == "active" {
return Ok(AdmissionEnqueueOutcome::Acquired);
}
if entry.status == "queued" && entry.state_id == state.id {
if Self::maybe_promote_existing_queued(tx, state, execution_id).await? {
return Ok(AdmissionEnqueueOutcome::Acquired);
}
return Ok(AdmissionEnqueueOutcome::Enqueued);
}
return Ok(AdmissionEnqueueOutcome::Enqueued);
}
let active_count = Self::active_count(tx, state.id).await?;
let queued_count = Self::queued_count(tx, state.id).await?;
if active_count < state.max_concurrent as i64 && queued_count == 0 {
let queue_order = Self::allocate_queue_order(tx, state.id).await?;
Self::insert_entry(
tx,
state.id,
execution_id,
"active",
queue_order,
Utc::now(),
)
.await?;
Self::increment_total_enqueued(tx, state.id).await?;
return Ok(AdmissionEnqueueOutcome::Acquired);
}
if !allow_queue {
return Ok(AdmissionEnqueueOutcome::Enqueued);
}
if queued_count >= max_queue_length as i64 {
return Err(anyhow::anyhow!(
"Queue full for action {}: maximum {} entries",
state.action_id,
max_queue_length
)
.into());
}
let queue_order = Self::allocate_queue_order(tx, state.id).await?;
Self::insert_entry(
tx,
state.id,
execution_id,
"queued",
queue_order,
Utc::now(),
)
.await?;
Self::increment_total_enqueued(tx, state.id).await?;
Ok(AdmissionEnqueueOutcome::Enqueued)
}
async fn maybe_promote_existing_queued(
tx: &mut Transaction<'_, Postgres>,
state: &AdmissionState,
execution_id: Id,
) -> Result<bool> {
let active_count = Self::active_count(tx, state.id).await?;
if active_count >= state.max_concurrent as i64 {
return Ok(false);
}
let front_execution_id = sqlx::query_scalar::<Postgres, Id>(
r#"
SELECT execution_id
FROM execution_admission_entry
WHERE state_id = $1
AND status = 'queued'
ORDER BY queue_order ASC
LIMIT 1
"#,
)
.bind(state.id)
.fetch_optional(&mut **tx)
.await?;
if front_execution_id != Some(execution_id) {
return Ok(false);
}
sqlx::query(
r#"
UPDATE execution_admission_entry
SET status = 'active',
activated_at = NOW()
WHERE execution_id = $1
AND state_id = $2
AND status = 'queued'
"#,
)
.bind(execution_id)
.bind(state.id)
.execute(&mut **tx)
.await?;
Ok(true)
}
async fn promote_next_queued(
tx: &mut Transaction<'_, Postgres>,
state: &AdmissionState,
) -> Result<Option<Id>> {
let next_execution_id = sqlx::query_scalar::<Postgres, Id>(
r#"
SELECT execution_id
FROM execution_admission_entry
WHERE state_id = $1
AND status = 'queued'
ORDER BY queue_order ASC
LIMIT 1
"#,
)
.bind(state.id)
.fetch_optional(&mut **tx)
.await?;
if let Some(next_execution_id) = next_execution_id {
sqlx::query(
r#"
UPDATE execution_admission_entry
SET status = 'active',
activated_at = NOW()
WHERE execution_id = $1
AND state_id = $2
AND status = 'queued'
"#,
)
.bind(next_execution_id)
.bind(state.id)
.execute(&mut **tx)
.await?;
}
Ok(next_execution_id)
}
async fn lock_state(
tx: &mut Transaction<'_, Postgres>,
action_id: Id,
group_key: Option<String>,
max_concurrent: u32,
) -> Result<AdmissionState> {
sqlx::query(
r#"
INSERT INTO execution_admission_state (action_id, group_key, max_concurrent)
VALUES ($1, $2, $3)
ON CONFLICT (action_id, group_key_normalized)
DO UPDATE SET max_concurrent = EXCLUDED.max_concurrent
"#,
)
.bind(action_id)
.bind(group_key.clone())
.bind(max_concurrent as i32)
.execute(&mut **tx)
.await?;
let state = sqlx::query(
r#"
SELECT id, action_id, group_key, max_concurrent
FROM execution_admission_state
WHERE action_id = $1
AND group_key_normalized = COALESCE($2, '')
FOR UPDATE
"#,
)
.bind(action_id)
.bind(group_key)
.fetch_one(&mut **tx)
.await?;
Ok(AdmissionState {
id: state.try_get("id")?,
action_id: state.try_get("action_id")?,
group_key: state.try_get("group_key")?,
max_concurrent: state.try_get("max_concurrent")?,
})
}
async fn lock_existing_state(
tx: &mut Transaction<'_, Postgres>,
action_id: Id,
group_key: Option<String>,
) -> Result<Option<AdmissionState>> {
let row = sqlx::query(
r#"
SELECT id, action_id, group_key, max_concurrent
FROM execution_admission_state
WHERE action_id = $1
AND group_key_normalized = COALESCE($2, '')
FOR UPDATE
"#,
)
.bind(action_id)
.bind(group_key)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|state| AdmissionState {
id: state.try_get("id").expect("state.id"),
action_id: state.try_get("action_id").expect("state.action_id"),
group_key: state.try_get("group_key").expect("state.group_key"),
max_concurrent: state
.try_get("max_concurrent")
.expect("state.max_concurrent"),
}))
}
async fn find_execution_entry(
tx: &mut Transaction<'_, Postgres>,
execution_id: Id,
) -> Result<Option<ExecutionEntry>> {
let row = sqlx::query(
r#"
SELECT
e.state_id,
s.action_id,
s.group_key,
e.execution_id,
e.status,
e.queue_order,
e.enqueued_at
FROM execution_admission_entry e
JOIN execution_admission_state s ON s.id = e.state_id
WHERE e.execution_id = $1
"#,
)
.bind(execution_id)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|entry| ExecutionEntry {
state_id: entry.try_get("state_id").expect("entry.state_id"),
action_id: entry.try_get("action_id").expect("entry.action_id"),
group_key: entry.try_get("group_key").expect("entry.group_key"),
status: entry.try_get("status").expect("entry.status"),
queue_order: entry.try_get("queue_order").expect("entry.queue_order"),
enqueued_at: entry.try_get("enqueued_at").expect("entry.enqueued_at"),
}))
}
async fn find_execution_entry_for_update(
tx: &mut Transaction<'_, Postgres>,
execution_id: Id,
) -> Result<Option<ExecutionEntry>> {
let row = sqlx::query(
r#"
SELECT
e.state_id,
s.action_id,
s.group_key,
e.execution_id,
e.status,
e.queue_order,
e.enqueued_at
FROM execution_admission_entry e
JOIN execution_admission_state s ON s.id = e.state_id
WHERE e.execution_id = $1
FOR UPDATE OF e, s
"#,
)
.bind(execution_id)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|entry| ExecutionEntry {
state_id: entry.try_get("state_id").expect("entry.state_id"),
action_id: entry.try_get("action_id").expect("entry.action_id"),
group_key: entry.try_get("group_key").expect("entry.group_key"),
status: entry.try_get("status").expect("entry.status"),
queue_order: entry.try_get("queue_order").expect("entry.queue_order"),
enqueued_at: entry.try_get("enqueued_at").expect("entry.enqueued_at"),
}))
}
async fn active_count(tx: &mut Transaction<'_, Postgres>, state_id: Id) -> Result<i64> {
Ok(sqlx::query_scalar::<Postgres, i64>(
r#"
SELECT COUNT(*)
FROM execution_admission_entry
WHERE state_id = $1
AND status = 'active'
"#,
)
.bind(state_id)
.fetch_one(&mut **tx)
.await?)
}
async fn queued_count(tx: &mut Transaction<'_, Postgres>, state_id: Id) -> Result<i64> {
Ok(sqlx::query_scalar::<Postgres, i64>(
r#"
SELECT COUNT(*)
FROM execution_admission_entry
WHERE state_id = $1
AND status = 'queued'
"#,
)
.bind(state_id)
.fetch_one(&mut **tx)
.await?)
}
async fn insert_entry(
tx: &mut Transaction<'_, Postgres>,
state_id: Id,
execution_id: Id,
status: &str,
queue_order: i64,
enqueued_at: DateTime<Utc>,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO execution_admission_entry (
state_id, execution_id, status, queue_order, enqueued_at, activated_at
) VALUES (
$1, $2, $3, $4, $5,
CASE WHEN $3 = 'active' THEN NOW() ELSE NULL END
)
"#,
)
.bind(state_id)
.bind(execution_id)
.bind(status)
.bind(queue_order)
.bind(enqueued_at)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn allocate_queue_order(tx: &mut Transaction<'_, Postgres>, state_id: Id) -> Result<i64> {
let queue_order = sqlx::query_scalar::<Postgres, i64>(
r#"
UPDATE execution_admission_state
SET next_queue_order = next_queue_order + 1
WHERE id = $1
RETURNING next_queue_order - 1
"#,
)
.bind(state_id)
.fetch_one(&mut **tx)
.await?;
Ok(queue_order)
}
async fn increment_total_enqueued(
tx: &mut Transaction<'_, Postgres>,
state_id: Id,
) -> Result<()> {
sqlx::query(
r#"
UPDATE execution_admission_state
SET total_enqueued = total_enqueued + 1
WHERE id = $1
"#,
)
.bind(state_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn increment_total_completed(
tx: &mut Transaction<'_, Postgres>,
state_id: Id,
) -> Result<()> {
sqlx::query(
r#"
UPDATE execution_admission_state
SET total_completed = total_completed + 1
WHERE id = $1
"#,
)
.bind(state_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn refresh_queue_stats(tx: &mut Transaction<'_, Postgres>, action_id: Id) -> Result<()> {
let Some(stats) = Self::get_queue_stats_from_tx(tx, action_id).await? else {
QueueStatsRepository::delete(&mut **tx, action_id).await?;
return Ok(());
};
QueueStatsRepository::upsert(
&mut **tx,
UpsertQueueStatsInput {
action_id,
queue_length: stats.queue_length as i32,
active_count: stats.active_count as i32,
max_concurrent: stats.max_concurrent as i32,
oldest_enqueued_at: stats.oldest_enqueued_at,
total_enqueued: stats.total_enqueued as i64,
total_completed: stats.total_completed as i64,
},
)
.await?;
Ok(())
}
async fn get_queue_stats_from_tx(
tx: &mut Transaction<'_, Postgres>,
action_id: Id,
) -> Result<Option<AdmissionQueueStats>> {
let row = sqlx::query(
r#"
WITH state_rows AS (
SELECT
COUNT(*) AS state_count,
COALESCE(SUM(max_concurrent), 0) AS max_concurrent,
COALESCE(SUM(total_enqueued), 0) AS total_enqueued,
COALESCE(SUM(total_completed), 0) AS total_completed
FROM execution_admission_state
WHERE action_id = $1
),
entry_rows AS (
SELECT
COUNT(*) FILTER (WHERE e.status = 'queued') AS queue_length,
COUNT(*) FILTER (WHERE e.status = 'active') AS active_count,
MIN(e.enqueued_at) FILTER (WHERE e.status = 'queued') AS oldest_enqueued_at
FROM execution_admission_state s
LEFT JOIN execution_admission_entry e ON e.state_id = s.id
WHERE s.action_id = $1
)
SELECT
sr.state_count,
er.queue_length,
er.active_count,
sr.max_concurrent,
er.oldest_enqueued_at,
sr.total_enqueued,
sr.total_completed
FROM state_rows sr
CROSS JOIN entry_rows er
"#,
)
.bind(action_id)
.fetch_one(&mut **tx)
.await?;
let state_count: i64 = row.try_get("state_count")?;
if state_count == 0 {
return Ok(None);
}
Ok(Some(AdmissionQueueStats {
action_id,
queue_length: row.try_get::<i64, _>("queue_length")? as usize,
active_count: row.try_get::<i64, _>("active_count")? as u32,
max_concurrent: row.try_get::<i64, _>("max_concurrent")? as u32,
oldest_enqueued_at: row.try_get("oldest_enqueued_at")?,
total_enqueued: row.try_get::<i64, _>("total_enqueued")? as u64,
total_completed: row.try_get::<i64, _>("total_completed")? as u64,
}))
}
}

View File

@@ -33,6 +33,7 @@ pub mod artifact;
pub mod entity_history;
pub mod event;
pub mod execution;
pub mod execution_admission;
pub mod identity;
pub mod inquiry;
pub mod key;
@@ -53,6 +54,7 @@ pub use artifact::{ArtifactRepository, ArtifactVersionRepository};
pub use entity_history::EntityHistoryRepository;
pub use event::{EnforcementRepository, EventRepository};
pub use execution::ExecutionRepository;
pub use execution_admission::ExecutionAdmissionRepository;
pub use identity::{IdentityRepository, PermissionAssignmentRepository, PermissionSetRepository};
pub use inquiry::InquiryRepository;
pub use key::KeyRepository;

View File

@@ -3,7 +3,7 @@
//! Provides database operations for queue statistics persistence.
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, QueryBuilder};
use sqlx::{Executor, PgPool, Postgres, QueryBuilder};
use crate::error::Result;
use crate::models::Id;
@@ -38,7 +38,10 @@ pub struct QueueStatsRepository;
impl QueueStatsRepository {
/// Upsert queue statistics (insert or update)
pub async fn upsert(pool: &PgPool, input: UpsertQueueStatsInput) -> Result<QueueStats> {
pub async fn upsert<'e, E>(executor: E, input: UpsertQueueStatsInput) -> Result<QueueStats>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let stats = sqlx::query_as::<Postgres, QueueStats>(
r#"
INSERT INTO queue_stats (
@@ -69,14 +72,17 @@ impl QueueStatsRepository {
.bind(input.oldest_enqueued_at)
.bind(input.total_enqueued)
.bind(input.total_completed)
.fetch_one(pool)
.fetch_one(executor)
.await?;
Ok(stats)
}
/// Get queue statistics for a specific action
pub async fn find_by_action(pool: &PgPool, action_id: Id) -> Result<Option<QueueStats>> {
pub async fn find_by_action<'e, E>(executor: E, action_id: Id) -> Result<Option<QueueStats>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let stats = sqlx::query_as::<Postgres, QueueStats>(
r#"
SELECT
@@ -93,14 +99,17 @@ impl QueueStatsRepository {
"#,
)
.bind(action_id)
.fetch_optional(pool)
.fetch_optional(executor)
.await?;
Ok(stats)
}
/// List all queue statistics with active queues (queue_length > 0 or active_count > 0)
pub async fn list_active(pool: &PgPool) -> Result<Vec<QueueStats>> {
pub async fn list_active<'e, E>(executor: E) -> Result<Vec<QueueStats>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let stats = sqlx::query_as::<Postgres, QueueStats>(
r#"
SELECT
@@ -117,14 +126,17 @@ impl QueueStatsRepository {
ORDER BY last_updated DESC
"#,
)
.fetch_all(pool)
.fetch_all(executor)
.await?;
Ok(stats)
}
/// List all queue statistics
pub async fn list_all(pool: &PgPool) -> Result<Vec<QueueStats>> {
pub async fn list_all<'e, E>(executor: E) -> Result<Vec<QueueStats>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let stats = sqlx::query_as::<Postgres, QueueStats>(
r#"
SELECT
@@ -140,14 +152,17 @@ impl QueueStatsRepository {
ORDER BY last_updated DESC
"#,
)
.fetch_all(pool)
.fetch_all(executor)
.await?;
Ok(stats)
}
/// Delete queue statistics for a specific action
pub async fn delete(pool: &PgPool, action_id: Id) -> Result<bool> {
pub async fn delete<'e, E>(executor: E, action_id: Id) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query(
r#"
DELETE FROM queue_stats
@@ -155,7 +170,7 @@ impl QueueStatsRepository {
"#,
)
.bind(action_id)
.execute(pool)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
@@ -163,7 +178,7 @@ impl QueueStatsRepository {
/// Batch upsert multiple queue statistics
pub async fn batch_upsert(
pool: &PgPool,
executor: &PgPool,
inputs: Vec<UpsertQueueStatsInput>,
) -> Result<Vec<QueueStats>> {
if inputs.is_empty() {
@@ -213,14 +228,17 @@ impl QueueStatsRepository {
let stats = query_builder
.build_query_as::<QueueStats>()
.fetch_all(pool)
.fetch_all(executor)
.await?;
Ok(stats)
}
/// Clear stale statistics (older than specified duration)
pub async fn clear_stale(pool: &PgPool, older_than_seconds: i64) -> Result<u64> {
pub async fn clear_stale<'e, E>(executor: E, older_than_seconds: i64) -> Result<u64>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query(
r#"
DELETE FROM queue_stats
@@ -230,7 +248,7 @@ impl QueueStatsRepository {
"#,
)
.bind(older_than_seconds)
.execute(pool)
.execute(executor)
.await?;
Ok(result.rows_affected())

View File

@@ -411,6 +411,12 @@ impl WorkflowDefinitionRepository {
pub struct WorkflowExecutionRepository;
#[derive(Debug, Clone)]
pub struct WorkflowExecutionCreateOrGetResult {
pub workflow_execution: WorkflowExecution,
pub created: bool,
}
impl Repository for WorkflowExecutionRepository {
type Entity = WorkflowExecution;
fn table_name() -> &'static str {
@@ -606,6 +612,71 @@ impl Delete for WorkflowExecutionRepository {
}
impl WorkflowExecutionRepository {
pub async fn find_by_id_for_update<'e, E>(
executor: E,
id: Id,
) -> Result<Option<WorkflowExecution>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, WorkflowExecution>(
"SELECT id, execution, workflow_def, current_tasks, completed_tasks, failed_tasks, skipped_tasks,
variables, task_graph, status, error_message, paused, pause_reason, created, updated
FROM workflow_execution
WHERE id = $1
FOR UPDATE"
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn create_or_get_by_execution<'e, E>(
executor: E,
input: CreateWorkflowExecutionInput,
) -> Result<WorkflowExecutionCreateOrGetResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let inserted = sqlx::query_as::<_, WorkflowExecution>(
"INSERT INTO workflow_execution
(execution, workflow_def, task_graph, variables, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (execution) DO NOTHING
RETURNING id, execution, workflow_def, current_tasks, completed_tasks, failed_tasks, skipped_tasks,
variables, task_graph, status, error_message, paused, pause_reason, created, updated"
)
.bind(input.execution)
.bind(input.workflow_def)
.bind(&input.task_graph)
.bind(&input.variables)
.bind(input.status)
.fetch_optional(executor)
.await?;
if let Some(workflow_execution) = inserted {
return Ok(WorkflowExecutionCreateOrGetResult {
workflow_execution,
created: true,
});
}
let workflow_execution = Self::find_by_execution(executor, input.execution)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"workflow_execution for parent execution {} disappeared after conflict",
input.execution
)
})?;
Ok(WorkflowExecutionCreateOrGetResult {
workflow_execution,
created: false,
})
}
/// Find workflow execution by the parent execution ID
pub async fn find_by_execution<'e, E>(
executor: E,

View File

@@ -172,6 +172,7 @@ impl WorkflowLoader {
}
// Read and parse YAML
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow files come from previously discovered pack directories under packs_base_dir.
let content = fs::read_to_string(&file.path)
.await
.map_err(|e| Error::validation(format!("Failed to read workflow file: {}", e)))?;
@@ -292,6 +293,7 @@ impl WorkflowLoader {
pack_name: &str,
) -> Result<Vec<WorkflowFile>> {
let mut workflow_files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow scanning only traverses pack workflow directories derived from packs_base_dir.
let mut entries = fs::read_dir(workflows_dir)
.await
.map_err(|e| Error::validation(format!("Failed to read workflows directory: {}", e)))?;

View File

@@ -1430,3 +1430,70 @@ async fn test_enforcement_resolved_at_lifecycle() {
assert!(updated.resolved_at.is_some());
assert!(updated.resolved_at.unwrap() >= enforcement.created);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_update_loaded_enforcement_uses_loaded_locator() {
let pool = create_test_pool().await.unwrap();
let pack = PackFixture::new_unique("targeted_update_pack")
.create(&pool)
.await
.unwrap();
let trigger = TriggerFixture::new_unique(Some(pack.id), Some(pack.r#ref.clone()), "webhook")
.create(&pool)
.await
.unwrap();
let action = ActionFixture::new_unique(pack.id, &pack.r#ref, "action")
.create(&pool)
.await
.unwrap();
use attune_common::repositories::rule::{CreateRuleInput, RuleRepository};
let rule = RuleRepository::create(
&pool,
CreateRuleInput {
r#ref: format!("{}.test_rule", pack.r#ref),
pack: pack.id,
pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(),
description: Some("Test".to_string()),
action: action.id,
action_ref: action.r#ref.clone(),
trigger: trigger.id,
trigger_ref: trigger.r#ref.clone(),
conditions: json!({}),
action_params: json!({}),
trigger_params: json!({}),
enabled: true,
is_adhoc: false,
},
)
.await
.unwrap();
let enforcement = EnforcementFixture::new_unique(Some(rule.id), &rule.r#ref, &trigger.r#ref)
.create(&pool)
.await
.unwrap();
let updated = EnforcementRepository::update_loaded(
&pool,
&enforcement,
UpdateEnforcementInput {
status: Some(EnforcementStatus::Processed),
payload: None,
resolved_at: Some(chrono::Utc::now()),
},
)
.await
.unwrap();
assert_eq!(updated.id, enforcement.id);
assert_eq!(updated.created, enforcement.created);
assert_eq!(updated.rule_ref, enforcement.rule_ref);
assert_eq!(updated.status, EnforcementStatus::Processed);
assert!(updated.resolved_at.is_some());
}

View File

@@ -1153,3 +1153,108 @@ async fn test_execution_result_json() {
assert_eq!(updated.result, Some(complex_result));
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_claim_for_scheduling_succeeds_once() {
let pool = create_test_pool().await.unwrap();
let pack = PackFixture::new_unique("claim_pack")
.create(&pool)
.await
.unwrap();
let action = ActionFixture::new_unique(pack.id, &pack.r#ref, "claim_action")
.create(&pool)
.await
.unwrap();
let created = ExecutionRepository::create(
&pool,
CreateExecutionInput {
action: Some(action.id),
action_ref: action.r#ref.clone(),
config: None,
env_vars: None,
parent: None,
enforcement: None,
executor: None,
worker: None,
status: ExecutionStatus::Requested,
result: None,
workflow_task: None,
},
)
.await
.unwrap();
let first = ExecutionRepository::claim_for_scheduling(&pool, created.id, None)
.await
.unwrap();
let second = ExecutionRepository::claim_for_scheduling(&pool, created.id, None)
.await
.unwrap();
assert_eq!(first.unwrap().status, ExecutionStatus::Scheduling);
assert!(second.is_none());
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_update_if_status_only_updates_matching_row() {
let pool = create_test_pool().await.unwrap();
let pack = PackFixture::new_unique("conditional_pack")
.create(&pool)
.await
.unwrap();
let action = ActionFixture::new_unique(pack.id, &pack.r#ref, "conditional_action")
.create(&pool)
.await
.unwrap();
let created = ExecutionRepository::create(
&pool,
CreateExecutionInput {
action: Some(action.id),
action_ref: action.r#ref.clone(),
config: None,
env_vars: None,
parent: None,
enforcement: None,
executor: None,
worker: None,
status: ExecutionStatus::Scheduling,
result: None,
workflow_task: None,
},
)
.await
.unwrap();
let updated = ExecutionRepository::update_if_status(
&pool,
created.id,
ExecutionStatus::Scheduling,
UpdateExecutionInput {
status: Some(ExecutionStatus::Scheduled),
worker: Some(77),
..Default::default()
},
)
.await
.unwrap();
let skipped = ExecutionRepository::update_if_status(
&pool,
created.id,
ExecutionStatus::Scheduling,
UpdateExecutionInput {
status: Some(ExecutionStatus::Failed),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.unwrap().status, ExecutionStatus::Scheduled);
assert!(skipped.is_none());
}

View File

@@ -182,6 +182,7 @@ mod tests {
#[test]
fn test_decode_valid_token() {
// Valid JWT with exp and iat claims
// nosemgrep: generic.secrets.security.detected-jwt-token.detected-jwt-token -- This is a non-secret test fixture with a dummy signature used only for JWT parsing tests.
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZW5zb3I6Y29yZS50aW1lciIsImlhdCI6MTcwNjM1NjQ5NiwiZXhwIjoxNzE0MTMyNDk2fQ.signature";
let manager = TokenRefreshManager::new(

View File

@@ -11,7 +11,10 @@
use anyhow::Result;
use attune_common::{
mq::{Consumer, ExecutionCompletedPayload, MessageEnvelope, Publisher},
mq::{
Consumer, ExecutionCompletedPayload, ExecutionRequestedPayload, MessageEnvelope,
MessageType, MqError, Publisher,
},
repositories::{execution::ExecutionRepository, FindById},
};
use sqlx::PgPool;
@@ -36,6 +39,19 @@ pub struct CompletionListener {
}
impl CompletionListener {
fn retryable_mq_error(error: &anyhow::Error) -> Option<MqError> {
let mq_error = error.downcast_ref::<MqError>()?;
Some(match mq_error {
MqError::Connection(msg) => MqError::Connection(msg.clone()),
MqError::Channel(msg) => MqError::Channel(msg.clone()),
MqError::Publish(msg) => MqError::Publish(msg.clone()),
MqError::Timeout(msg) => MqError::Timeout(msg.clone()),
MqError::Pool(msg) => MqError::Pool(msg.clone()),
MqError::Lapin(err) => MqError::Connection(err.to_string()),
_ => return None,
})
}
/// Create a new completion listener
pub fn new(
pool: PgPool,
@@ -82,6 +98,9 @@ impl CompletionListener {
{
error!("Error processing execution completion: {}", e);
// Return error to trigger nack with requeue
if let Some(mq_err) = Self::retryable_mq_error(&e) {
return Err(mq_err);
}
return Err(
format!("Failed to process execution completion: {}", e).into()
);
@@ -138,7 +157,11 @@ impl CompletionListener {
"Failed to advance workflow for execution {}: {}",
execution_id, e
);
// Continue processing — don't fail the entire completion
if let Some(mq_err) = Self::retryable_mq_error(&e) {
return Err(mq_err.into());
}
// Non-retryable workflow advancement errors are logged but
// do not fail the entire completion processing path.
}
}
@@ -187,19 +210,39 @@ impl CompletionListener {
action_id, execution_id
);
match queue_manager.notify_completion(action_id).await {
Ok(notified) => {
if notified {
match queue_manager.release_active_slot(execution_id).await {
Ok(release) => {
if let Some(release) = release {
if let Some(next_execution_id) = release.next_execution_id {
info!(
"Queue slot released for action {}, next execution notified",
action_id
"Queue slot released for action {}, next execution {} can proceed",
action_id, next_execution_id
);
if let Err(republish_err) = Self::publish_execution_requested(
pool,
publisher,
action_id,
next_execution_id,
)
.await
{
queue_manager
.restore_active_slot(execution_id, &release)
.await?;
return Err(republish_err);
}
} else {
debug!(
"Queue slot released for action {}, no executions waiting",
action_id
);
}
} else {
debug!(
"Execution {} had no active queue slot to release",
execution_id
);
}
}
Err(e) => {
error!(
@@ -225,6 +268,38 @@ impl CompletionListener {
Ok(())
}
async fn publish_execution_requested(
pool: &PgPool,
publisher: &Publisher,
action_id: i64,
execution_id: i64,
) -> Result<()> {
let execution = ExecutionRepository::find_by_id(pool, execution_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Execution {} not found", execution_id))?;
let payload = ExecutionRequestedPayload {
execution_id,
action_id: Some(action_id),
action_ref: execution.action_ref.clone(),
parent_id: execution.parent,
enforcement_id: execution.enforcement,
config: execution.config.clone(),
};
let envelope = MessageEnvelope::new(MessageType::ExecutionRequested, payload)
.with_source("executor-completion-listener");
publisher.publish_envelope(&envelope).await?;
debug!(
"Republished deferred ExecutionRequested for execution {}",
execution_id
);
Ok(())
}
}
#[cfg(test)]
@@ -233,13 +308,13 @@ mod tests {
use crate::queue_manager::ExecutionQueueManager;
#[tokio::test]
async fn test_notify_completion_releases_slot() {
async fn test_release_active_slot_releases_slot() {
let queue_manager = Arc::new(ExecutionQueueManager::with_defaults());
let action_id = 1;
// Simulate acquiring a slot
queue_manager
.enqueue_and_wait(action_id, 100, 1)
.enqueue_and_wait(action_id, 100, 1, None)
.await
.unwrap();
@@ -249,8 +324,9 @@ mod tests {
assert_eq!(stats.queue_length, 0);
// Simulate completion notification
let notified = queue_manager.notify_completion(action_id).await.unwrap();
assert!(!notified); // No one waiting
let release = queue_manager.release_active_slot(100).await.unwrap();
assert!(release.is_some());
assert_eq!(release.unwrap().next_execution_id, None);
// Verify slot is released
let stats = queue_manager.get_queue_stats(action_id).await.unwrap();
@@ -258,13 +334,13 @@ mod tests {
}
#[tokio::test]
async fn test_notify_completion_wakes_waiting() {
async fn test_release_active_slot_wakes_waiting() {
let queue_manager = Arc::new(ExecutionQueueManager::with_defaults());
let action_id = 1;
// Fill capacity
queue_manager
.enqueue_and_wait(action_id, 100, 1)
.enqueue_and_wait(action_id, 100, 1, None)
.await
.unwrap();
@@ -272,7 +348,7 @@ mod tests {
let queue_manager_clone = queue_manager.clone();
let handle = tokio::spawn(async move {
queue_manager_clone
.enqueue_and_wait(action_id, 101, 1)
.enqueue_and_wait(action_id, 101, 1, None)
.await
.unwrap();
});
@@ -286,8 +362,8 @@ mod tests {
assert_eq!(stats.queue_length, 1);
// Notify completion
let notified = queue_manager.notify_completion(action_id).await.unwrap();
assert!(notified); // Should wake the waiting execution
let release = queue_manager.release_active_slot(100).await.unwrap();
assert_eq!(release.unwrap().next_execution_id, Some(101));
// Wait for queued execution to proceed
handle.await.unwrap();
@@ -306,7 +382,7 @@ mod tests {
// Fill capacity
queue_manager
.enqueue_and_wait(action_id, 100, 1)
.enqueue_and_wait(action_id, 100, 1, None)
.await
.unwrap();
@@ -320,7 +396,7 @@ mod tests {
let handle = tokio::spawn(async move {
queue_manager
.enqueue_and_wait(action_id, exec_id, 1)
.enqueue_and_wait(action_id, exec_id, 1, None)
.await
.unwrap();
order.lock().await.push(exec_id);
@@ -333,9 +409,13 @@ mod tests {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Release them one by one
for _ in 0..3 {
for execution_id in 100..103 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
queue_manager.notify_completion(action_id).await.unwrap();
let release = queue_manager
.release_active_slot(execution_id)
.await
.unwrap();
assert!(release.is_some());
}
// Wait for all to complete
@@ -351,11 +431,11 @@ mod tests {
#[tokio::test]
async fn test_completion_with_no_queue() {
let queue_manager = Arc::new(ExecutionQueueManager::with_defaults());
let action_id = 999; // Non-existent action
let execution_id = 999; // Non-existent execution
// Should succeed but not notify anyone
let result = queue_manager.notify_completion(action_id).await;
let result = queue_manager.release_active_slot(execution_id).await;
assert!(result.is_ok());
assert!(!result.unwrap());
assert!(result.unwrap().is_none());
}
}

View File

@@ -14,7 +14,7 @@ use attune_common::{
error::Error,
models::ExecutionStatus,
mq::{Consumer, ConsumerConfig, MessageEnvelope, MessageType, MqResult},
repositories::{execution::UpdateExecutionInput, ExecutionRepository, FindById, Update},
repositories::{execution::UpdateExecutionInput, ExecutionRepository, FindById},
};
use chrono::Utc;
use serde_json::json;
@@ -179,13 +179,12 @@ async fn handle_execution_requested(
}
};
// Only fail if still in a non-terminal state
if !matches!(
execution.status,
ExecutionStatus::Scheduled | ExecutionStatus::Running
) {
// Only scheduled executions are still legitimately owned by the scheduler.
// If the execution already moved to running or a terminal state, this DLQ
// delivery is stale and must not overwrite newer state.
if execution.status != ExecutionStatus::Scheduled {
info!(
"Execution {} already in terminal state {:?}, skipping",
"Execution {} already left Scheduled state ({:?}), skipping stale DLQ handling",
execution_id, execution.status
);
return Ok(()); // Acknowledge to remove from queue
@@ -193,6 +192,12 @@ async fn handle_execution_requested(
// Get worker info from payload for better error message
let worker_id = envelope.payload.get("worker_id").and_then(|v| v.as_i64());
let scheduled_attempt_updated_at = envelope
.payload
.get("scheduled_attempt_updated_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
let error_message = if let Some(wid) = worker_id {
format!(
@@ -214,26 +219,87 @@ async fn handle_execution_requested(
..Default::default()
};
match ExecutionRepository::update(pool, execution_id, update_input).await {
Ok(_) => {
if let Some(timestamp) = scheduled_attempt_updated_at {
// Guard on both status and the exact updated_at from when the execution was
// scheduled — prevents overwriting state that changed after this DLQ message
// was enqueued.
match ExecutionRepository::update_if_status_and_updated_at(
pool,
execution_id,
ExecutionStatus::Scheduled,
timestamp,
update_input,
)
.await
{
Ok(Some(_)) => {
info!(
"Successfully failed execution {} due to worker queue expiration",
execution_id
);
Ok(())
}
Ok(None) => {
info!(
"Skipping DLQ failure for execution {} because it already left Scheduled state",
execution_id
);
Ok(())
}
Err(e) => {
error!(
"Failed to update execution {} to failed state: {}",
execution_id, e
);
// Return error to nack and potentially retry
Err(attune_common::mq::MqError::Consume(format!(
"Failed to update execution: {}",
e
)))
}
}
} else {
// Fallback for DLQ messages that predate the scheduled_attempt_updated_at
// field. Use a status-only guard — same safety guarantee as the original code
// (never overwrites terminal or running state).
warn!(
"DLQ message for execution {} lacks scheduled_attempt_updated_at; \
falling back to status-only guard",
execution_id
);
match ExecutionRepository::update_if_status(
pool,
execution_id,
ExecutionStatus::Scheduled,
update_input,
)
.await
{
Ok(Some(_)) => {
info!(
"Successfully failed execution {} due to worker queue expiration (status-only guard)",
execution_id
);
Ok(())
}
Ok(None) => {
info!(
"Skipping DLQ failure for execution {} because it already left Scheduled state",
execution_id
);
Ok(())
}
Err(e) => {
error!(
"Failed to update execution {} to failed state: {}",
execution_id, e
);
Err(attune_common::mq::MqError::Consume(format!(
"Failed to update execution: {}",
e
)))
}
}
}
}
/// Create a dead letter consumer configuration

View File

@@ -19,7 +19,7 @@ use attune_common::{
event::{EnforcementRepository, EventRepository, UpdateEnforcementInput},
execution::{CreateExecutionInput, ExecutionRepository},
rule::RuleRepository,
Create, FindById, Update,
FindById,
},
};
@@ -116,6 +116,14 @@ impl EnforcementProcessor {
.await?
.ok_or_else(|| anyhow::anyhow!("Enforcement not found: {}", enforcement_id))?;
if enforcement.status != EnforcementStatus::Created {
debug!(
"Enforcement {} already left Created state ({:?}), skipping duplicate processing",
enforcement_id, enforcement.status
);
return Ok(());
}
// Fetch associated rule
let rule = RuleRepository::find_by_id(
pool,
@@ -135,7 +143,7 @@ impl EnforcementProcessor {
// Evaluate whether to create execution
if Self::should_create_execution(&enforcement, &rule, event.as_ref())? {
Self::create_execution(
let execution_created = Self::create_execution(
pool,
publisher,
policy_enforcer,
@@ -145,10 +153,10 @@ impl EnforcementProcessor {
)
.await?;
// Update enforcement status to Processed after successful execution creation
EnforcementRepository::update(
let updated = EnforcementRepository::update_loaded_if_status(
pool,
enforcement_id,
&enforcement,
EnforcementStatus::Created,
UpdateEnforcementInput {
status: Some(EnforcementStatus::Processed),
payload: None,
@@ -157,17 +165,27 @@ impl EnforcementProcessor {
)
.await?;
debug!("Updated enforcement {} status to Processed", enforcement_id);
if updated.is_some() {
debug!(
"Updated enforcement {} status to Processed after {} execution path",
enforcement_id,
if execution_created {
"new"
} else {
"idempotent"
}
);
}
} else {
info!(
"Skipping execution creation for enforcement: {}",
enforcement_id
);
// Update enforcement status to Disabled since it was not actionable
EnforcementRepository::update(
let updated = EnforcementRepository::update_loaded_if_status(
pool,
enforcement_id,
&enforcement,
EnforcementStatus::Created,
UpdateEnforcementInput {
status: Some(EnforcementStatus::Disabled),
payload: None,
@@ -176,11 +194,13 @@ impl EnforcementProcessor {
)
.await?;
if updated.is_some() {
debug!(
"Updated enforcement {} status to Disabled (skipped)",
enforcement_id
);
}
}
Ok(())
}
@@ -230,11 +250,11 @@ impl EnforcementProcessor {
async fn create_execution(
pool: &PgPool,
publisher: &Publisher,
policy_enforcer: &PolicyEnforcer,
_policy_enforcer: &PolicyEnforcer,
_queue_manager: &ExecutionQueueManager,
enforcement: &Enforcement,
rule: &Rule,
) -> Result<()> {
) -> Result<bool> {
// Extract action ID — should_create_execution already verified it's Some,
// but guard defensively here as well.
let action_id = match rule.action {
@@ -257,33 +277,10 @@ impl EnforcementProcessor {
enforcement.id, rule.id, action_id
);
let pack_id = rule.pack;
let action_ref = &rule.action_ref;
// Enforce policies and wait for queue slot if needed
info!(
"Enforcing policies for action {} (enforcement: {})",
action_id, enforcement.id
);
// Use enforcement ID for queue tracking (execution doesn't exist yet)
if let Err(e) = policy_enforcer
.enforce_and_wait(action_id, Some(pack_id), enforcement.id)
.await
{
error!(
"Policy enforcement failed for enforcement {}: {}",
enforcement.id, e
);
return Err(e);
}
info!(
"Policy check passed and queue slot obtained for enforcement: {}",
enforcement.id
);
// Now create execution in database (we have a queue slot)
// Create the execution row first; scheduler-side policy enforcement
// now handles both rule-triggered and manual executions uniformly.
let execution_input = CreateExecutionInput {
action: Some(action_id),
action_ref: action_ref.clone(),
@@ -298,21 +295,36 @@ impl EnforcementProcessor {
workflow_task: None, // Non-workflow execution
};
let execution = ExecutionRepository::create(pool, execution_input).await?;
let execution_result = ExecutionRepository::create_top_level_for_enforcement_if_absent(
pool,
execution_input,
enforcement.id,
)
.await?;
let execution = execution_result.execution;
if execution_result.created {
info!(
"Created execution: {} for enforcement: {}",
execution.id, enforcement.id
);
} else {
info!(
"Reusing execution: {} for enforcement: {}",
execution.id, enforcement.id
);
}
// Publish ExecutionRequested message
if execution_result.created
|| execution.status == attune_common::models::enums::ExecutionStatus::Requested
{
let payload = ExecutionRequestedPayload {
execution_id: execution.id,
action_id: Some(action_id),
action_ref: action_ref.clone(),
parent_id: None,
enforcement_id: Some(enforcement.id),
config: enforcement.config.clone(),
config: execution.config.clone(),
};
let envelope =
@@ -331,11 +343,12 @@ impl EnforcementProcessor {
"Published execution.requested message for execution: {} (enforcement: {}, action: {})",
execution.id, enforcement.id, action_id
);
}
// NOTE: Queue slot will be released when worker publishes execution.completed
// and CompletionListener calls queue_manager.notify_completion(action_id)
Ok(())
Ok(execution_result.created)
}
}

View File

@@ -19,7 +19,7 @@ use attune_common::{
event::{CreateEnforcementInput, EnforcementRepository, EventRepository},
pack::PackRepository,
rule::RuleRepository,
Create, FindById, List,
FindById, List,
},
template_resolver::{resolve_templates, TemplateContext},
};
@@ -206,14 +206,23 @@ impl EventProcessor {
conditions: rule.conditions.clone(),
};
let enforcement = EnforcementRepository::create(pool, create_input).await?;
let enforcement_result =
EnforcementRepository::create_or_get_by_rule_event(pool, create_input).await?;
let enforcement = enforcement_result.enforcement;
if enforcement_result.created {
info!(
"Enforcement {} created for rule {} (event: {})",
enforcement.id, rule.r#ref, event.id
);
} else {
info!(
"Reusing enforcement {} for rule {} (event: {})",
enforcement.id, rule.r#ref, event.id
);
}
// Publish EnforcementCreated message
if enforcement_result.created || enforcement.status == EnforcementStatus::Created {
let enforcement_payload = EnforcementCreatedPayload {
enforcement_id: enforcement.id,
rule_id: Some(rule.id),
@@ -223,7 +232,8 @@ impl EventProcessor {
payload: payload.clone(),
};
let envelope = MessageEnvelope::new(MessageType::EnforcementCreated, enforcement_payload)
let envelope =
MessageEnvelope::new(MessageType::EnforcementCreated, enforcement_payload)
.with_source("event-processor");
publisher.publish_envelope(&envelope).await?;
@@ -232,6 +242,7 @@ impl EventProcessor {
"Published EnforcementCreated message for enforcement {}",
enforcement.id
);
}
Ok(())
}

View File

@@ -9,13 +9,14 @@
use anyhow::Result;
use attune_common::{
error::Error as AttuneError,
models::{enums::InquiryStatus, inquiry::Inquiry, Execution, Id},
mq::{
Consumer, InquiryCreatedPayload, InquiryRespondedPayload, MessageEnvelope, MessageType,
Publisher,
},
repositories::{
execution::{ExecutionRepository, UpdateExecutionInput},
execution::{ExecutionRepository, UpdateExecutionInput, SELECT_COLUMNS},
inquiry::{CreateInquiryInput, InquiryRepository},
Create, FindById, Update,
},
@@ -28,6 +29,8 @@ use tracing::{debug, error, info, warn};
/// Special key in action result to indicate an inquiry should be created
pub const INQUIRY_RESULT_KEY: &str = "__inquiry";
const INQUIRY_ID_RESULT_KEY: &str = "__inquiry_id";
const INQUIRY_CREATED_PUBLISHED_RESULT_KEY: &str = "__inquiry_created_published";
/// Structure for inquiry data in action results
#[derive(Debug, Clone, serde::Deserialize)]
@@ -104,26 +107,71 @@ impl InquiryHandler {
let inquiry_request: InquiryRequest = serde_json::from_value(inquiry_value.clone())?;
Ok(inquiry_request)
}
}
/// Returns true when `e` represents a PostgreSQL unique constraint violation (code 23505).
fn is_db_unique_violation(e: &AttuneError) -> bool {
if let AttuneError::Database(sqlx_err) = e {
return sqlx_err
.as_database_error()
.and_then(|db| db.code())
.as_deref()
== Some("23505");
}
false
}
impl InquiryHandler {
/// Create an inquiry for an execution and pause it
pub async fn create_inquiry_from_result(
pool: &PgPool,
publisher: &Publisher,
execution_id: Id,
result: &JsonValue,
_result: &JsonValue,
) -> Result<Inquiry> {
info!("Creating inquiry for execution {}", execution_id);
// Extract inquiry request
let inquiry_request = Self::extract_inquiry_request(result)?;
let mut tx = pool.begin().await?;
let execution = sqlx::query_as::<_, Execution>(&format!(
"SELECT {SELECT_COLUMNS} FROM execution WHERE id = $1 FOR UPDATE"
))
.bind(execution_id)
.fetch_one(&mut *tx)
.await?;
// Calculate timeout if specified
let mut result = execution
.result
.clone()
.ok_or_else(|| anyhow::anyhow!("Execution {} has no result", execution_id))?;
let inquiry_request = Self::extract_inquiry_request(&result)?;
let timeout_at = inquiry_request
.timeout_seconds
.map(|seconds| Utc::now() + chrono::Duration::seconds(seconds));
// Create inquiry in database
let inquiry_input = CreateInquiryInput {
let existing_inquiry_id = result
.get(INQUIRY_ID_RESULT_KEY)
.and_then(|value| value.as_i64());
let published = result
.get(INQUIRY_CREATED_PUBLISHED_RESULT_KEY)
.and_then(|value| value.as_bool())
.unwrap_or(false);
let (inquiry, should_publish) = if let Some(inquiry_id) = existing_inquiry_id {
let inquiry = InquiryRepository::find_by_id(&mut *tx, inquiry_id)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Inquiry {} referenced by execution {} result not found",
inquiry_id,
execution_id
)
})?;
let should_publish = !published && inquiry.status == InquiryStatus::Pending;
(inquiry, should_publish)
} else {
let create_result = InquiryRepository::create(
&mut *tx,
CreateInquiryInput {
execution: execution_id,
prompt: inquiry_request.prompt.clone(),
response_schema: inquiry_request.response_schema.clone(),
@@ -131,20 +179,55 @@ impl InquiryHandler {
status: InquiryStatus::Pending,
response: None,
timeout_at,
},
)
.await;
let inquiry = match create_result {
Ok(inq) => inq,
Err(e) => {
// Unique constraint violation (23505): another replica already
// created the inquiry for this execution. Treat as idempotent
// success — drop the aborted transaction and return the existing row.
if is_db_unique_violation(&e) {
info!(
"Inquiry for execution {} already created by another replica \
(unique constraint 23505); treating as idempotent",
execution_id
);
// tx is in an aborted state; dropping it issues ROLLBACK.
drop(tx);
let inquiries =
InquiryRepository::find_by_execution(pool, execution_id).await?;
let existing = inquiries.into_iter().next().ok_or_else(|| {
anyhow::anyhow!(
"Inquiry for execution {} not found after unique constraint violation",
execution_id
)
})?;
return Ok(existing);
}
return Err(e.into());
}
};
let inquiry = InquiryRepository::create(pool, inquiry_input).await?;
Self::set_inquiry_result_metadata(&mut result, inquiry.id, false)?;
ExecutionRepository::update(
&mut *tx,
execution_id,
UpdateExecutionInput {
result: Some(result),
..Default::default()
},
)
.await?;
info!(
"Created inquiry {} for execution {}",
inquiry.id, execution_id
);
(inquiry, true)
};
// Update execution status to paused/waiting
// Note: We use a special status or keep it as "running" with inquiry tracking
// For now, we'll keep status as-is and track via inquiry relationship
tx.commit().await?;
// Publish InquiryCreated message
if should_publish {
let payload = InquiryCreatedPayload {
inquiry_id: inquiry.id,
execution_id,
@@ -158,15 +241,64 @@ impl InquiryHandler {
MessageEnvelope::new(MessageType::InquiryCreated, payload).with_source("executor");
publisher.publish_envelope(&envelope).await?;
Self::mark_inquiry_created_published(pool, execution_id).await?;
debug!(
"Published InquiryCreated message for inquiry {}",
inquiry.id
);
}
Ok(inquiry)
}
fn set_inquiry_result_metadata(
result: &mut JsonValue,
inquiry_id: Id,
published: bool,
) -> Result<()> {
let obj = result
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("execution result is not a JSON object"))?;
obj.insert(
INQUIRY_ID_RESULT_KEY.to_string(),
JsonValue::Number(inquiry_id.into()),
);
obj.insert(
INQUIRY_CREATED_PUBLISHED_RESULT_KEY.to_string(),
JsonValue::Bool(published),
);
Ok(())
}
async fn mark_inquiry_created_published(pool: &PgPool, execution_id: Id) -> Result<()> {
let execution = ExecutionRepository::find_by_id(pool, execution_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Execution {} not found", execution_id))?;
let mut result = execution
.result
.clone()
.ok_or_else(|| anyhow::anyhow!("Execution {} has no result", execution_id))?;
let inquiry_id = result
.get(INQUIRY_ID_RESULT_KEY)
.and_then(|value| value.as_i64())
.ok_or_else(|| anyhow::anyhow!("Execution {} missing __inquiry_id", execution_id))?;
Self::set_inquiry_result_metadata(&mut result, inquiry_id, true)?;
ExecutionRepository::update(
pool,
execution_id,
UpdateExecutionInput {
result: Some(result),
..Default::default()
},
)
.await?;
Ok(())
}
/// Handle an inquiry response message
async fn handle_inquiry_response(
pool: &PgPool,
@@ -235,9 +367,13 @@ impl InquiryHandler {
if let Some(obj) = updated_result.as_object_mut() {
obj.insert("__inquiry_response".to_string(), response.clone());
obj.insert(
"__inquiry_id".to_string(),
INQUIRY_ID_RESULT_KEY.to_string(),
JsonValue::Number(inquiry.id.into()),
);
obj.insert(
INQUIRY_CREATED_PUBLISHED_RESULT_KEY.to_string(),
JsonValue::Bool(true),
);
}
// Update execution with new result

View File

@@ -10,14 +10,23 @@
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sqlx::PgPool;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use tracing::{debug, info, warn};
use attune_common::models::{enums::ExecutionStatus, Id};
use attune_common::{
models::{
enums::{ExecutionStatus, PolicyMethod},
Id, Policy,
},
repositories::action::PolicyRepository,
};
use crate::queue_manager::ExecutionQueueManager;
use crate::queue_manager::{
ExecutionQueueManager, QueuedRemovalOutcome, SlotEnqueueOutcome, SlotReleaseOutcome,
};
/// Policy violation type
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -79,16 +88,38 @@ impl std::fmt::Display for PolicyViolation {
}
/// Execution policy configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPolicy {
/// Rate limit: maximum executions per time window
pub rate_limit: Option<RateLimit>,
/// Concurrency limit: maximum concurrent executions
pub concurrency_limit: Option<u32>,
/// How a concurrency violation should be handled.
pub concurrency_method: PolicyMethod,
/// Parameter paths used to scope concurrency grouping.
pub concurrency_parameters: Vec<String>,
/// Resource quotas
pub quotas: Option<HashMap<String, u64>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchedulingPolicyOutcome {
Ready,
Queued,
}
impl Default for ExecutionPolicy {
fn default() -> Self {
Self {
rate_limit: None,
concurrency_limit: None,
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
}
}
}
/// Rate limit configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimit {
@@ -98,6 +129,25 @@ pub struct RateLimit {
pub window_seconds: u32,
}
#[derive(Debug, Clone)]
struct ResolvedConcurrencyPolicy {
limit: u32,
method: PolicyMethod,
parameters: Vec<String>,
}
impl From<Policy> for ExecutionPolicy {
fn from(policy: Policy) -> Self {
Self {
rate_limit: None,
concurrency_limit: Some(policy.threshold as u32),
concurrency_method: policy.method,
concurrency_parameters: policy.parameters,
quotas: None,
}
}
}
/// Policy enforcement scope
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] // Used in tests
@@ -185,6 +235,174 @@ impl PolicyEnforcer {
self.action_policies.insert(action_id, policy);
}
/// Best-effort release for a slot acquired during scheduling when the
/// execution never reaches the worker/completion path.
pub async fn release_execution_slot(
&self,
execution_id: Id,
) -> Result<Option<SlotReleaseOutcome>> {
match &self.queue_manager {
Some(queue_manager) => queue_manager.release_active_slot(execution_id).await,
None => Ok(None),
}
}
pub async fn restore_execution_slot(
&self,
execution_id: Id,
outcome: &SlotReleaseOutcome,
) -> Result<()> {
match &self.queue_manager {
Some(queue_manager) => {
queue_manager
.restore_active_slot(execution_id, outcome)
.await
}
None => Ok(()),
}
}
pub async fn remove_queued_execution(
&self,
execution_id: Id,
) -> Result<Option<QueuedRemovalOutcome>> {
match &self.queue_manager {
Some(queue_manager) => queue_manager.remove_queued_execution(execution_id).await,
None => Ok(None),
}
}
pub async fn restore_queued_execution(&self, outcome: &QueuedRemovalOutcome) -> Result<()> {
match &self.queue_manager {
Some(queue_manager) => queue_manager.restore_queued_execution(outcome).await,
None => Ok(()),
}
}
pub async fn enforce_for_scheduling(
&self,
action_id: Id,
pack_id: Option<Id>,
execution_id: Id,
config: Option<&JsonValue>,
) -> Result<SchedulingPolicyOutcome> {
if let Some(violation) = self
.check_policies_except_concurrency(action_id, pack_id)
.await?
{
warn!("Policy violation for action {}: {}", action_id, violation);
return Err(anyhow::anyhow!("Policy violation: {}", violation));
}
if let Some(concurrency) = self.resolve_concurrency_policy(action_id, pack_id).await? {
let group_key = self.build_parameter_group_key(&concurrency.parameters, config);
if let Some(queue_manager) = &self.queue_manager {
match concurrency.method {
PolicyMethod::Enqueue => {
return match queue_manager
.enqueue(action_id, execution_id, concurrency.limit, group_key)
.await?
{
SlotEnqueueOutcome::Acquired => Ok(SchedulingPolicyOutcome::Ready),
SlotEnqueueOutcome::Enqueued => Ok(SchedulingPolicyOutcome::Queued),
};
}
PolicyMethod::Cancel => {
let outcome = queue_manager
.try_acquire(
action_id,
execution_id,
concurrency.limit,
group_key.clone(),
)
.await?;
if !outcome.acquired {
let violation = PolicyViolation::ConcurrencyLimitExceeded {
limit: concurrency.limit,
current_count: outcome.current_count,
};
warn!("Policy violation for action {}: {}", action_id, violation);
return Err(anyhow::anyhow!("Policy violation: {}", violation));
}
}
}
} else {
let scope = PolicyScope::Action(action_id);
if let Some(violation) = self
.check_concurrency_limit(concurrency.limit, &scope)
.await?
{
return Err(anyhow::anyhow!("Policy violation: {}", violation));
}
}
}
Ok(SchedulingPolicyOutcome::Ready)
}
async fn resolve_policy(&self, action_id: Id, pack_id: Option<Id>) -> Result<ExecutionPolicy> {
if let Some(policy) = self.action_policies.get(&action_id) {
return Ok(policy.clone());
}
if let Some(policy) = PolicyRepository::find_latest_by_action(&self.pool, action_id).await?
{
return Ok(policy.into());
}
if let Some(pack_id) = pack_id {
if let Some(policy) = self.pack_policies.get(&pack_id) {
return Ok(policy.clone());
}
if let Some(policy) = PolicyRepository::find_latest_by_pack(&self.pool, pack_id).await?
{
return Ok(policy.into());
}
}
if let Some(policy) = PolicyRepository::find_latest_global(&self.pool).await? {
return Ok(policy.into());
}
Ok(self.global_policy.clone())
}
async fn resolve_concurrency_policy(
&self,
action_id: Id,
pack_id: Option<Id>,
) -> Result<Option<ResolvedConcurrencyPolicy>> {
let policy = self.resolve_policy(action_id, pack_id).await?;
Ok(policy
.concurrency_limit
.map(|limit| ResolvedConcurrencyPolicy {
limit,
method: policy.concurrency_method,
parameters: policy.concurrency_parameters,
}))
}
fn build_parameter_group_key(
&self,
parameter_paths: &[String],
config: Option<&JsonValue>,
) -> Option<String> {
if parameter_paths.is_empty() {
return None;
}
let values: BTreeMap<String, JsonValue> = parameter_paths
.iter()
.map(|path| (path.clone(), extract_parameter_value(config, path)))
.collect();
serde_json::to_string(&values).ok()
}
/// Get the concurrency limit for a specific action
///
/// Returns the most specific concurrency limit found:
@@ -192,6 +410,7 @@ impl PolicyEnforcer {
/// 2. Pack policy
/// 3. Global policy
/// 4. None (unlimited)
#[allow(dead_code)]
pub fn get_concurrency_limit(&self, action_id: Id, pack_id: Option<Id>) -> Option<u32> {
// Check action-specific policy first
if let Some(policy) = self.action_policies.get(&action_id) {
@@ -213,79 +432,6 @@ impl PolicyEnforcer {
self.global_policy.concurrency_limit
}
/// Enforce policies and wait in queue if necessary
///
/// This method combines policy checking with queue management to ensure:
/// 1. Policy violations are detected early
/// 2. FIFO ordering is maintained when capacity is limited
/// 3. Executions wait efficiently for available slots
///
/// # Arguments
/// * `action_id` - The action to execute
/// * `pack_id` - The pack containing the action
/// * `execution_id` - The execution/enforcement ID for queue tracking
///
/// # Returns
/// * `Ok(())` - Policy allows execution and queue slot obtained
/// * `Err(PolicyViolation)` - Policy prevents execution
/// * `Err(QueueError)` - Queue timeout or other queue error
pub async fn enforce_and_wait(
&self,
action_id: Id,
pack_id: Option<Id>,
execution_id: Id,
) -> Result<()> {
// First, check for policy violations (rate limit, quotas, etc.)
// Note: We skip concurrency check here since queue manages that
if let Some(violation) = self
.check_policies_except_concurrency(action_id, pack_id)
.await?
{
warn!("Policy violation for action {}: {}", action_id, violation);
return Err(anyhow::anyhow!("Policy violation: {}", violation));
}
// If queue manager is available, use it for concurrency control
if let Some(queue_manager) = &self.queue_manager {
let concurrency_limit = self
.get_concurrency_limit(action_id, pack_id)
.unwrap_or(u32::MAX); // Default to unlimited if no policy
debug!(
"Enqueuing execution {} for action {} with concurrency limit {}",
execution_id, action_id, concurrency_limit
);
queue_manager
.enqueue_and_wait(action_id, execution_id, concurrency_limit)
.await?;
info!(
"Execution {} obtained queue slot for action {}",
execution_id, action_id
);
} else {
// No queue manager - use legacy polling behavior
debug!(
"No queue manager configured, using legacy policy wait for action {}",
action_id
);
if let Some(concurrency_limit) = self.get_concurrency_limit(action_id, pack_id) {
// Check concurrency with old method
let scope = PolicyScope::Action(action_id);
if let Some(violation) = self
.check_concurrency_limit(concurrency_limit, &scope)
.await?
{
return Err(anyhow::anyhow!("Policy violation: {}", violation));
}
}
}
Ok(())
}
/// Check policies except concurrency (which is handled by queue)
async fn check_policies_except_concurrency(
&self,
@@ -631,11 +777,28 @@ impl PolicyEnforcer {
}
}
fn extract_parameter_value(config: Option<&JsonValue>, path: &str) -> JsonValue {
let mut current = match config {
Some(value) => value,
None => return JsonValue::Null,
};
for segment in path.split('.') {
match current {
JsonValue::Object(map) => match map.get(segment) {
Some(next) => current = next,
None => return JsonValue::Null,
},
_ => return JsonValue::Null,
}
}
current.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::queue_manager::QueueConfig;
use tokio::time::{sleep, Duration};
#[test]
fn test_policy_violation_display() {
@@ -665,6 +828,8 @@ mod tests {
let policy = ExecutionPolicy::default();
assert!(policy.rate_limit.is_none());
assert!(policy.concurrency_limit.is_none());
assert_eq!(policy.concurrency_method, PolicyMethod::Enqueue);
assert!(policy.concurrency_parameters.is_empty());
assert!(policy.quotas.is_none());
}
@@ -769,132 +934,25 @@ mod tests {
}
#[tokio::test]
async fn test_enforce_and_wait_with_queue_manager() {
async fn test_build_parameter_group_key_uses_exact_values() {
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/test").unwrap();
let queue_manager = Arc::new(ExecutionQueueManager::with_defaults());
let mut enforcer = PolicyEnforcer::with_queue_manager(pool, queue_manager.clone());
// Set concurrency limit
enforcer.set_action_policy(
1,
ExecutionPolicy {
concurrency_limit: Some(1),
..Default::default()
},
);
// First execution should proceed immediately
let result = enforcer.enforce_and_wait(1, None, 100).await;
assert!(result.is_ok());
// Check queue stats
let stats = queue_manager.get_queue_stats(1).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 0);
let enforcer = PolicyEnforcer::new(pool);
let config = serde_json::json!({
"environment": "prod",
"target": {
"region": "us-east-1"
}
#[tokio::test]
async fn test_enforce_and_wait_fifo_ordering() {
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/test").unwrap();
let queue_manager = Arc::new(ExecutionQueueManager::with_defaults());
let mut enforcer = PolicyEnforcer::with_queue_manager(pool, queue_manager.clone());
enforcer.set_action_policy(
1,
ExecutionPolicy {
concurrency_limit: Some(1),
..Default::default()
},
);
let enforcer = Arc::new(enforcer);
// First execution
let result = enforcer.enforce_and_wait(1, None, 100).await;
assert!(result.is_ok());
// Queue multiple executions
let execution_order = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let mut handles = vec![];
for exec_id in 101..=103 {
let enforcer = enforcer.clone();
let queue_manager = queue_manager.clone();
let order = execution_order.clone();
let handle = tokio::spawn(async move {
enforcer.enforce_and_wait(1, None, exec_id).await.unwrap();
order.lock().await.push(exec_id);
// Simulate work
sleep(Duration::from_millis(10)).await;
queue_manager.notify_completion(1).await.unwrap();
});
handles.push(handle);
}
// Give tasks time to queue
sleep(Duration::from_millis(100)).await;
// Release first execution
queue_manager.notify_completion(1).await.unwrap();
// Wait for all
for handle in handles {
handle.await.unwrap();
}
// Verify FIFO order
let order = execution_order.lock().await;
assert_eq!(*order, vec![101, 102, 103]);
}
#[tokio::test]
async fn test_enforce_and_wait_without_queue_manager() {
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/test").unwrap();
let mut enforcer = PolicyEnforcer::new(pool);
// Set unlimited concurrency
enforcer.set_action_policy(
1,
ExecutionPolicy {
concurrency_limit: None,
..Default::default()
},
let group_key = enforcer.build_parameter_group_key(
&["target.region".to_string(), "environment".to_string()],
Some(&config),
);
// Should work without queue manager (legacy behavior)
let result = enforcer.enforce_and_wait(1, None, 100).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_enforce_and_wait_queue_timeout() {
let config = QueueConfig {
max_queue_length: 100,
queue_timeout_seconds: 1, // Short timeout for test
enable_metrics: true,
};
let pool = sqlx::PgPool::connect_lazy("postgresql://localhost/test").unwrap();
let queue_manager = Arc::new(ExecutionQueueManager::new(config));
let mut enforcer = PolicyEnforcer::with_queue_manager(pool, queue_manager.clone());
// Set concurrency limit
enforcer.set_action_policy(
1,
ExecutionPolicy {
concurrency_limit: Some(1),
..Default::default()
},
assert_eq!(
group_key.as_deref(),
Some("{\"environment\":\"prod\",\"target.region\":\"us-east-1\"}")
);
// First execution proceeds
enforcer.enforce_and_wait(1, None, 100).await.unwrap();
// Second execution should timeout
let result = enforcer.enforce_and_wait(1, None, 101).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("timeout"));
}
// Integration tests would require database setup

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -297,6 +297,7 @@ impl ExecutorService {
self.inner.pool.clone(),
self.inner.publisher.clone(),
Arc::new(scheduler_consumer),
self.inner.policy_enforcer.clone(),
);
handles.push(tokio::spawn(async move { scheduler.start().await }));

View File

@@ -12,7 +12,10 @@ use anyhow::Result;
use attune_common::{
models::{enums::ExecutionStatus, Execution},
mq::{MessageEnvelope, MessageType, Publisher},
repositories::execution::SELECT_COLUMNS as EXECUTION_COLUMNS,
repositories::{
execution::{UpdateExecutionInput, SELECT_COLUMNS as EXECUTION_COLUMNS},
ExecutionRepository,
},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -178,20 +181,27 @@ impl ExecutionTimeoutMonitor {
"original_status": "scheduled"
});
// Update execution status in database
sqlx::query(
"UPDATE execution
SET status = $1,
result = $2,
updated = NOW()
WHERE id = $3",
let updated = ExecutionRepository::update_if_status_and_updated_before(
&self.pool,
execution_id,
ExecutionStatus::Scheduled,
self.calculate_cutoff_time(),
UpdateExecutionInput {
status: Some(ExecutionStatus::Failed),
result: Some(result.clone()),
..Default::default()
},
)
.bind(ExecutionStatus::Failed)
.bind(&result)
.bind(execution_id)
.execute(&self.pool)
.await?;
if updated.is_none() {
debug!(
"Skipping timeout failure for execution {} because it already left Scheduled or is no longer stale",
execution_id
);
return Ok(());
}
info!("Execution {} marked as failed in database", execution_id);
// Publish completion notification

View File

@@ -155,6 +155,7 @@ impl WorkflowLoader {
}
// Read and parse YAML
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow files come from pack directories already discovered under packs_base_dir.
let content = fs::read_to_string(&file.path)
.await
.map_err(|e| Error::validation(format!("Failed to read workflow file: {}", e)))?;
@@ -265,6 +266,7 @@ impl WorkflowLoader {
pack_name: &str,
) -> Result<Vec<WorkflowFile>> {
let mut workflow_files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Executor workflow scanning only traverses pack-owned workflow directories.
let mut entries = fs::read_dir(workflows_dir)
.await
.map_err(|e| Error::validation(format!("Failed to read workflows directory: {}", e)))?;

View File

@@ -26,6 +26,7 @@ use attune_executor::queue_manager::{ExecutionQueueManager, QueueConfig};
use chrono::Utc;
use serde_json::json;
use sqlx::PgPool;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
@@ -172,6 +173,26 @@ async fn cleanup_test_data(pool: &PgPool, pack_id: i64) {
.ok();
}
async fn release_next_active(
manager: &ExecutionQueueManager,
active_execution_ids: &mut VecDeque<i64>,
) -> Option<i64> {
let execution_id = active_execution_ids
.pop_front()
.expect("Expected an active execution to release");
let release = manager
.release_active_slot(execution_id)
.await
.expect("Release should succeed")
.expect("Active execution should have a tracked slot");
if let Some(next_execution_id) = release.next_execution_id {
active_execution_ids.push_back(next_execution_id);
}
release.next_execution_id
}
#[tokio::test]
#[ignore] // Requires database
async fn test_fifo_ordering_with_database() {
@@ -198,8 +219,9 @@ async fn test_fifo_ordering_with_database() {
// Create first execution in database and enqueue
let first_exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let mut active_execution_ids = VecDeque::from([first_exec_id]);
manager
.enqueue_and_wait(action_id, first_exec_id, max_concurrent)
.enqueue_and_wait(action_id, first_exec_id, max_concurrent, None)
.await
.expect("First execution should enqueue");
@@ -222,7 +244,7 @@ async fn test_fifo_ordering_with_database() {
// Enqueue and wait
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
@@ -250,10 +272,7 @@ async fn test_fifo_ordering_with_database() {
// Release them one by one
for _ in 0..num_executions {
sleep(Duration::from_millis(50)).await;
manager
.notify_completion(action_id)
.await
.expect("Notify should succeed");
release_next_active(&manager, &mut active_execution_ids).await;
}
// Wait for all to complete
@@ -295,6 +314,7 @@ async fn test_high_concurrency_stress() {
let num_executions: i64 = 1000;
let execution_order = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
let execution_ids = Arc::new(Mutex::new(vec![None; num_executions as usize]));
println!("Starting stress test with {} executions...", num_executions);
let start_time = std::time::Instant::now();
@@ -305,6 +325,7 @@ async fn test_high_concurrency_stress() {
let manager_clone = manager.clone();
let action_ref_clone = action_ref.clone();
let order = execution_order.clone();
let ids = execution_ids.clone();
let handle = tokio::spawn(async move {
let exec_id = create_test_execution(
@@ -314,9 +335,10 @@ async fn test_high_concurrency_stress() {
ExecutionStatus::Requested,
)
.await;
ids.lock().await[i as usize] = Some(exec_id);
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
@@ -332,6 +354,7 @@ async fn test_high_concurrency_stress() {
let manager_clone = manager.clone();
let action_ref_clone = action_ref.clone();
let order = execution_order.clone();
let ids = execution_ids.clone();
let handle = tokio::spawn(async move {
let exec_id = create_test_execution(
@@ -341,9 +364,10 @@ async fn test_high_concurrency_stress() {
ExecutionStatus::Requested,
)
.await;
ids.lock().await[i as usize] = Some(exec_id);
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
@@ -376,15 +400,21 @@ async fn test_high_concurrency_stress() {
);
// Release all executions
let ids = execution_ids.lock().await;
let mut active_execution_ids = VecDeque::from(
ids.iter()
.take(max_concurrent as usize)
.map(|id| id.expect("Initial execution id should be recorded"))
.collect::<Vec<_>>(),
);
drop(ids);
println!("Releasing executions...");
for i in 0..num_executions {
if i % 100 == 0 {
println!("Released {} executions", i);
}
manager
.notify_completion(action_id)
.await
.expect("Notify should succeed");
release_next_active(&manager, &mut active_execution_ids).await;
// Small delay to allow queue processing
if i % 50 == 0 {
@@ -416,7 +446,7 @@ async fn test_high_concurrency_stress() {
"All executions should complete"
);
let expected: Vec<i64> = (0..num_executions).collect();
let expected: Vec<_> = (0..num_executions).collect();
assert_eq!(
*order, expected,
"Executions should complete in strict FIFO order"
@@ -461,9 +491,31 @@ async fn test_multiple_workers_simulation() {
let num_executions = 30;
let execution_order = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
let mut active_execution_ids = VecDeque::new();
// Spawn all executions
for i in 0..num_executions {
// Fill the initial worker slots deterministically.
for i in 0..max_concurrent {
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
active_execution_ids.push_back(exec_id);
let manager_clone = manager.clone();
let order = execution_order.clone();
let handle = tokio::spawn(async move {
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
order.lock().await.push(i);
});
handles.push(handle);
}
// Queue the remaining executions.
for i in max_concurrent..num_executions {
let pool_clone = pool.clone();
let manager_clone = manager.clone();
let action_ref_clone = action_ref.clone();
@@ -479,7 +531,7 @@ async fn test_multiple_workers_simulation() {
.await;
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
@@ -499,6 +551,8 @@ async fn test_multiple_workers_simulation() {
let worker_completions = Arc::new(Mutex::new(vec![0, 0, 0]));
let worker_completions_clone = worker_completions.clone();
let manager_clone = manager.clone();
let active_execution_ids = Arc::new(Mutex::new(active_execution_ids));
let active_execution_ids_clone = active_execution_ids.clone();
// Spawn worker simulators
let worker_handle = tokio::spawn(async move {
@@ -514,10 +568,8 @@ async fn test_multiple_workers_simulation() {
sleep(Duration::from_millis(delay)).await;
// Worker completes and notifies
manager_clone
.notify_completion(action_id)
.await
.expect("Notify should succeed");
let mut active_execution_ids = active_execution_ids_clone.lock().await;
release_next_active(&manager_clone, &mut active_execution_ids).await;
worker_completions_clone.lock().await[next_worker] += 1;
@@ -536,7 +588,7 @@ async fn test_multiple_workers_simulation() {
// Verify FIFO order maintained despite different worker speeds
let order = execution_order.lock().await;
let expected: Vec<i64> = (0..num_executions).collect();
let expected: Vec<_> = (0..num_executions).collect();
assert_eq!(
*order, expected,
"FIFO order should be maintained regardless of worker speed"
@@ -576,27 +628,30 @@ async fn test_cross_action_independence() {
let executions_per_action = 50;
let mut handles = vec![];
let mut action1_active = VecDeque::new();
let mut action2_active = VecDeque::new();
let mut action3_active = VecDeque::new();
// Spawn executions for all three actions simultaneously
for action_id in [action1_id, action2_id, action3_id] {
let action_ref = format!("fifo_test_action_{}_{}", suffix, action_id);
for i in 0..executions_per_action {
let pool_clone = pool.clone();
let manager_clone = manager.clone();
let action_ref_clone = action_ref.clone();
let handle = tokio::spawn(async move {
let exec_id = create_test_execution(
&pool_clone,
action_id,
&action_ref_clone,
ExecutionStatus::Requested,
)
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested)
.await;
match action_id {
id if id == action1_id && i == 0 => action1_active.push_back(exec_id),
id if id == action2_id && i == 0 => action2_active.push_back(exec_id),
id if id == action3_id && i == 0 => action3_active.push_back(exec_id),
_ => {}
}
let manager_clone = manager.clone();
let handle = tokio::spawn(async move {
manager_clone
.enqueue_and_wait(action_id, exec_id, 1)
.enqueue_and_wait(action_id, exec_id, 1, None)
.await
.expect("Enqueue should succeed");
@@ -634,18 +689,9 @@ async fn test_cross_action_independence() {
// Release all actions in an interleaved pattern
for i in 0..executions_per_action {
// Release one from each action
manager
.notify_completion(action1_id)
.await
.expect("Notify should succeed");
manager
.notify_completion(action2_id)
.await
.expect("Notify should succeed");
manager
.notify_completion(action3_id)
.await
.expect("Notify should succeed");
release_next_active(&manager, &mut action1_active).await;
release_next_active(&manager, &mut action2_active).await;
release_next_active(&manager, &mut action3_active).await;
if i % 10 == 0 {
sleep(Duration::from_millis(10)).await;
@@ -698,8 +744,9 @@ async fn test_cancellation_during_queue() {
// Fill capacity
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let mut active_execution_ids = VecDeque::from([exec_id]);
manager
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.unwrap();
@@ -722,7 +769,7 @@ async fn test_cancellation_during_queue() {
ids.lock().await.push(exec_id);
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
});
@@ -757,7 +804,7 @@ async fn test_cancellation_during_queue() {
// Release remaining
for _ in 0..8 {
manager.notify_completion(action_id).await.unwrap();
release_next_active(&manager, &mut active_execution_ids).await;
sleep(Duration::from_millis(20)).await;
}
@@ -798,17 +845,21 @@ async fn test_queue_stats_persistence() {
let max_concurrent = 5;
let num_executions = 50;
let mut active_execution_ids = VecDeque::new();
// Enqueue executions
for i in 0..num_executions {
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
if i < max_concurrent {
active_execution_ids.push_back(exec_id);
}
// Start the enqueue in background
let manager_clone = manager.clone();
tokio::spawn(async move {
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.ok();
});
@@ -838,7 +889,7 @@ async fn test_queue_stats_persistence() {
// Release all
for _ in 0..num_executions {
manager.notify_completion(action_id).await.unwrap();
release_next_active(&manager, &mut active_execution_ids).await;
sleep(Duration::from_millis(10)).await;
}
@@ -854,13 +905,122 @@ async fn test_queue_stats_persistence() {
assert_eq!(final_db_stats.queue_length, 0);
assert_eq!(final_mem_stats.queue_length, 0);
assert_eq!(final_db_stats.total_enqueued, num_executions);
assert_eq!(final_db_stats.total_completed, num_executions);
assert_eq!(final_db_stats.total_enqueued, num_executions as i64);
assert_eq!(final_db_stats.total_completed, num_executions as i64);
// Cleanup
cleanup_test_data(&pool, pack_id).await;
}
#[tokio::test]
#[ignore] // Requires database
async fn test_release_restore_recovers_active_slot_and_next_queue_head() {
let pool = setup_db().await;
let timestamp = Utc::now().timestamp();
let suffix = format!("restore_release_{}", timestamp);
let pack_id = create_test_pack(&pool, &suffix).await;
let pack_ref = format!("fifo_test_pack_{}", suffix);
let action_id = create_test_action(&pool, pack_id, &pack_ref, &suffix).await;
let action_ref = format!("fifo_test_action_{}", suffix);
let manager = ExecutionQueueManager::with_db_pool(QueueConfig::default(), pool.clone());
let first =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let second =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let third =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
manager.enqueue(action_id, first, 1, None).await.unwrap();
manager.enqueue(action_id, second, 1, None).await.unwrap();
manager.enqueue(action_id, third, 1, None).await.unwrap();
let stats = manager.get_queue_stats(action_id).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 2);
let release = manager
.release_active_slot(first)
.await
.unwrap()
.expect("first execution should own an active slot");
assert_eq!(release.next_execution_id, Some(second));
let stats = manager.get_queue_stats(action_id).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 1);
manager.restore_active_slot(first, &release).await.unwrap();
let stats = manager.get_queue_stats(action_id).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 2);
assert_eq!(stats.total_completed, 0);
let next = manager
.release_active_slot(first)
.await
.unwrap()
.expect("restored execution should still own the active slot");
assert_eq!(next.next_execution_id, Some(second));
cleanup_test_data(&pool, pack_id).await;
}
#[tokio::test]
#[ignore] // Requires database
async fn test_remove_restore_recovers_queued_execution_position() {
let pool = setup_db().await;
let timestamp = Utc::now().timestamp();
let suffix = format!("restore_queue_{}", timestamp);
let pack_id = create_test_pack(&pool, &suffix).await;
let pack_ref = format!("fifo_test_pack_{}", suffix);
let action_id = create_test_action(&pool, pack_id, &pack_ref, &suffix).await;
let action_ref = format!("fifo_test_action_{}", suffix);
let manager = ExecutionQueueManager::with_db_pool(QueueConfig::default(), pool.clone());
let first =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let second =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let third =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
manager.enqueue(action_id, first, 1, None).await.unwrap();
manager.enqueue(action_id, second, 1, None).await.unwrap();
manager.enqueue(action_id, third, 1, None).await.unwrap();
let removal = manager
.remove_queued_execution(second)
.await
.unwrap()
.expect("second execution should be queued");
assert_eq!(removal.next_execution_id, None);
let stats = manager.get_queue_stats(action_id).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 1);
manager.restore_queued_execution(&removal).await.unwrap();
let stats = manager.get_queue_stats(action_id).await.unwrap();
assert_eq!(stats.active_count, 1);
assert_eq!(stats.queue_length, 2);
let release = manager
.release_active_slot(first)
.await
.unwrap()
.expect("first execution should own the active slot");
assert_eq!(release.next_execution_id, Some(second));
cleanup_test_data(&pool, pack_id).await;
}
#[tokio::test]
#[ignore] // Requires database
async fn test_queue_full_rejection() {
@@ -888,7 +1048,7 @@ async fn test_queue_full_rejection() {
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
manager
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.unwrap();
@@ -900,7 +1060,7 @@ async fn test_queue_full_rejection() {
tokio::spawn(async move {
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.ok();
});
@@ -917,7 +1077,7 @@ async fn test_queue_full_rejection() {
let exec_id =
create_test_execution(&pool, action_id, &action_ref, ExecutionStatus::Requested).await;
let result = manager
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await;
assert!(result.is_err(), "Should reject when queue is full");
@@ -951,6 +1111,7 @@ async fn test_extreme_stress_10k_executions() {
let max_concurrent = 10;
let num_executions: i64 = 10000;
let completed = Arc::new(Mutex::new(0u64));
let execution_ids = Arc::new(Mutex::new(vec![None; num_executions as usize]));
println!(
"Starting extreme stress test with {} executions...",
@@ -965,6 +1126,7 @@ async fn test_extreme_stress_10k_executions() {
let manager_clone = manager.clone();
let action_ref_clone = action_ref.clone();
let completed_clone = completed.clone();
let ids = execution_ids.clone();
let handle = tokio::spawn(async move {
let exec_id = create_test_execution(
@@ -974,9 +1136,10 @@ async fn test_extreme_stress_10k_executions() {
ExecutionStatus::Requested,
)
.await;
ids.lock().await[i as usize] = Some(exec_id);
manager_clone
.enqueue_and_wait(action_id, exec_id, max_concurrent)
.enqueue_and_wait(action_id, exec_id, max_concurrent, None)
.await
.expect("Enqueue should succeed");
@@ -999,12 +1162,18 @@ async fn test_extreme_stress_10k_executions() {
println!("All executions spawned");
// Release all
let ids = execution_ids.lock().await;
let mut active_execution_ids = VecDeque::from(
ids.iter()
.take(max_concurrent as usize)
.map(|id| id.expect("Initial execution id should be recorded"))
.collect::<Vec<_>>(),
);
drop(ids);
let release_start = std::time::Instant::now();
for i in 0i64..num_executions {
manager
.notify_completion(action_id)
.await
.expect("Notify should succeed");
release_next_active(&manager, &mut active_execution_ids).await;
if i % 1000 == 0 {
println!("Released: {}", i);

View File

@@ -9,7 +9,7 @@
use attune_common::{
config::Config,
db::Database,
models::enums::ExecutionStatus,
models::enums::{ExecutionStatus, PolicyMethod},
repositories::{
action::{ActionRepository, CreateActionInput},
execution::{CreateExecutionInput, ExecutionRepository},
@@ -190,6 +190,8 @@ async fn test_global_rate_limit() {
window_seconds: 60,
}),
concurrency_limit: None,
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
@@ -242,6 +244,8 @@ async fn test_concurrency_limit() {
let policy = ExecutionPolicy {
rate_limit: None,
concurrency_limit: Some(2),
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
@@ -300,6 +304,8 @@ async fn test_action_specific_policy() {
window_seconds: 60,
}),
concurrency_limit: None,
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
enforcer.set_action_policy(action_id, action_policy);
@@ -345,6 +351,8 @@ async fn test_pack_specific_policy() {
let pack_policy = ExecutionPolicy {
rate_limit: None,
concurrency_limit: Some(1),
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
enforcer.set_pack_policy(pack_id, pack_policy);
@@ -388,6 +396,8 @@ async fn test_policy_priority() {
window_seconds: 60,
}),
concurrency_limit: None,
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
let mut enforcer = PolicyEnforcer::with_global_policy(pool.clone(), global_policy);
@@ -399,6 +409,8 @@ async fn test_policy_priority() {
window_seconds: 60,
}),
concurrency_limit: None,
concurrency_method: PolicyMethod::Enqueue,
concurrency_parameters: Vec::new(),
quotas: None,
};
enforcer.set_action_policy(action_id, action_policy);

View File

@@ -84,6 +84,7 @@ impl ArtifactManager {
// Store stdout
if !stdout.is_empty() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact filenames are fixed constants under an execution-scoped directory derived from the execution ID.
let stdout_path = exec_dir.join("stdout.log");
let mut file = fs::File::create(&stdout_path)
.await
@@ -117,6 +118,7 @@ impl ArtifactManager {
// Store stderr
if !stderr.is_empty() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact filenames are fixed constants under an execution-scoped directory derived from the execution ID.
let stderr_path = exec_dir.join("stderr.log");
let mut file = fs::File::create(&stderr_path)
.await
@@ -162,6 +164,7 @@ impl ArtifactManager {
.await
.map_err(|e| Error::Internal(format!("Failed to create execution directory: {}", e)))?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Result artifacts are written to a fixed filename inside the execution-scoped directory.
let result_path = exec_dir.join("result.json");
let result_json = serde_json::to_string_pretty(result)?;
@@ -209,6 +212,7 @@ impl ArtifactManager {
.await
.map_err(|e| Error::Internal(format!("Failed to create execution directory: {}", e)))?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Custom artifact paths are always rooted under the execution-scoped artifact directory.
let file_path = exec_dir.join(filename);
let mut file = fs::File::create(&file_path)
.await
@@ -246,6 +250,7 @@ impl ArtifactManager {
/// Read an artifact
pub async fn read_artifact(&self, artifact: &Artifact) -> Result<Vec<u8>> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact reads use paths previously created by the artifact manager inside the configured artifact root.
fs::read(&artifact.path)
.await
.map_err(|e| Error::Internal(format!("Failed to read artifact: {}", e)))

View File

@@ -474,6 +474,7 @@ impl ActionExecutor {
let actions_dir = pack_dir.join("actions");
let actions_dir_exists = actions_dir.exists();
let actions_dir_contents: Vec<String> = if actions_dir_exists {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Diagnostic directory listing is confined to the action pack directory derived from pack_ref.
std::fs::read_dir(&actions_dir)
.map(|entries| {
entries
@@ -543,6 +544,16 @@ impl ActionExecutor {
selected_runtime_version,
max_stdout_bytes: self.max_stdout_bytes,
max_stderr_bytes: self.max_stderr_bytes,
stdout_log_path: Some(
self.artifact_manager
.get_execution_dir(execution.id)
.join("stdout.log"),
),
stderr_log_path: Some(
self.artifact_manager
.get_execution_dir(execution.id)
.join("stderr.log"),
),
parameter_delivery: action.parameter_delivery,
parameter_format: action.parameter_format,
output_format: action.output_format,
@@ -892,6 +903,7 @@ impl ActionExecutor {
// Check if stderr log exists and is non-empty from artifact storage
let stderr_path = exec_dir.join("stderr.log");
if stderr_path.exists() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Log paths are fixed artifact filenames inside the execution-scoped directory.
if let Ok(contents) = tokio::fs::read_to_string(&stderr_path).await {
if !contents.trim().is_empty() {
result_data["stderr_log"] =
@@ -903,6 +915,7 @@ impl ActionExecutor {
// Check if stdout log exists from artifact storage
let stdout_path = exec_dir.join("stdout.log");
if stdout_path.exists() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Log paths are fixed artifact filenames inside the execution-scoped directory.
if let Ok(contents) = tokio::fs::read_to_string(&stdout_path).await {
if !contents.is_empty() {
result_data["stdout"] = serde_json::json!(contents);
@@ -990,7 +1003,11 @@ impl ActionExecutor {
..Default::default()
};
ExecutionRepository::update(&self.pool, execution_id, input).await?;
let execution = ExecutionRepository::find_by_id(&self.pool, execution_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Execution {} not found", execution_id))?;
ExecutionRepository::update_loaded(&self.pool, &execution, input).await?;
Ok(())
}

View File

@@ -200,6 +200,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -233,6 +235,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),

View File

@@ -2,9 +2,10 @@
//!
//! Provides bounded log writers that limit output size to prevent OOM issues.
use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
use tokio::io::{AsyncWrite, AsyncWriteExt};
const TRUNCATION_NOTICE_STDOUT: &str = "\n\n[OUTPUT TRUNCATED: stdout exceeded size limit]\n";
const TRUNCATION_NOTICE_STDERR: &str = "\n\n[OUTPUT TRUNCATED: stderr exceeded size limit]\n";
@@ -76,6 +77,15 @@ pub struct BoundedLogWriter {
truncation_notice: &'static str,
}
/// A file-backed writer that applies the same truncation policy as `BoundedLogWriter`.
pub struct BoundedLogFileWriter {
file: tokio::fs::File,
max_bytes: usize,
truncated: bool,
data_bytes_written: usize,
truncation_notice: &'static str,
}
impl BoundedLogWriter {
/// Create a new bounded log writer for stdout
pub fn new_stdout(max_bytes: usize) -> Self {
@@ -166,6 +176,76 @@ impl BoundedLogWriter {
}
}
impl BoundedLogFileWriter {
pub async fn new_stdout(path: &Path, max_bytes: usize) -> std::io::Result<Self> {
Self::create(path, max_bytes, TRUNCATION_NOTICE_STDOUT).await
}
pub async fn new_stderr(path: &Path, max_bytes: usize) -> std::io::Result<Self> {
Self::create(path, max_bytes, TRUNCATION_NOTICE_STDERR).await
}
async fn create(
path: &Path,
max_bytes: usize,
truncation_notice: &'static str,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.await?;
Ok(Self {
file,
max_bytes,
truncated: false,
data_bytes_written: 0,
truncation_notice,
})
}
pub async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
if self.truncated {
return Ok(());
}
let effective_limit = self.max_bytes.saturating_sub(NOTICE_RESERVE_BYTES);
let remaining_space = effective_limit.saturating_sub(self.data_bytes_written);
if remaining_space == 0 {
self.add_truncation_notice().await?;
return Ok(());
}
let bytes_to_write = std::cmp::min(buf.len(), remaining_space);
if bytes_to_write > 0 {
self.file.write_all(&buf[..bytes_to_write]).await?;
self.data_bytes_written += bytes_to_write;
}
if bytes_to_write < buf.len() {
self.add_truncation_notice().await?;
}
self.file.flush().await
}
async fn add_truncation_notice(&mut self) -> std::io::Result<()> {
if self.truncated {
return Ok(());
}
self.truncated = true;
self.file.write_all(self.truncation_notice.as_bytes()).await
}
}
impl AsyncWrite for BoundedLogWriter {
fn poll_write(
mut self: Pin<&mut Self>,

View File

@@ -48,7 +48,7 @@ pub use dependency::{
DependencyError, DependencyManager, DependencyManagerRegistry, DependencyResult,
DependencySpec, EnvironmentInfo,
};
pub use log_writer::{BoundedLogResult, BoundedLogWriter};
pub use log_writer::{BoundedLogFileWriter, BoundedLogResult, BoundedLogWriter};
pub use parameter_passing::{ParameterDeliveryConfig, PreparedParameters};
// Re-export parameter types from common
@@ -148,6 +148,12 @@ pub struct ExecutionContext {
/// Maximum stderr size in bytes (for log truncation)
pub max_stderr_bytes: usize,
/// Optional live stdout log path for incremental writes during execution.
pub stdout_log_path: Option<PathBuf>,
/// Optional live stderr log path for incremental writes during execution.
pub stderr_log_path: Option<PathBuf>,
/// How parameters should be delivered to the action
pub parameter_delivery: ParameterDelivery,
@@ -185,6 +191,8 @@ impl ExecutionContext {
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),

View File

@@ -5,10 +5,11 @@
use super::{
parameter_passing::{self, ParameterDeliveryConfig},
BoundedLogWriter, ExecutionContext, ExecutionResult, Runtime, RuntimeError, RuntimeResult,
BoundedLogFileWriter, BoundedLogWriter, ExecutionContext, ExecutionResult, Runtime,
RuntimeError, RuntimeResult,
};
use async_trait::async_trait;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -45,6 +46,8 @@ impl NativeRuntime {
timeout: Option<u64>,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
stdout_log_path: Option<&Path>,
stderr_log_path: Option<&Path>,
) -> RuntimeResult<ExecutionResult> {
let start = Instant::now();
@@ -131,6 +134,8 @@ impl NativeRuntime {
let mut stdout_writer = BoundedLogWriter::new_stdout(max_stdout_bytes);
let mut stderr_writer = BoundedLogWriter::new_stderr(max_stderr_bytes);
let mut stdout_file = open_live_log_file(stdout_log_path, max_stdout_bytes, true).await?;
let mut stderr_file = open_live_log_file(stderr_log_path, max_stderr_bytes, false).await?;
// Create buffered readers
let mut stdout_reader = BufReader::new(stdout_handle);
@@ -147,6 +152,9 @@ impl NativeRuntime {
if stdout_writer.write_all(&line).await.is_err() {
break;
}
if let Some(file) = stdout_file.as_mut() {
let _ = file.write_all(&line).await;
}
}
Err(_) => break,
}
@@ -164,6 +172,9 @@ impl NativeRuntime {
if stderr_writer.write_all(&line).await.is_err() {
break;
}
if let Some(file) = stderr_file.as_mut() {
let _ = file.write_all(&line).await;
}
}
Err(_) => break,
}
@@ -352,6 +363,8 @@ impl Runtime for NativeRuntime {
context.timeout,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.stdout_log_path.as_deref(),
context.stderr_log_path.as_deref(),
)
.await
}
@@ -401,6 +414,23 @@ impl Runtime for NativeRuntime {
}
}
async fn open_live_log_file(
path: Option<&Path>,
max_bytes: usize,
is_stdout: bool,
) -> std::io::Result<Option<BoundedLogFileWriter>> {
let Some(path) = path else {
return Ok(None);
};
let writer = if is_stdout {
BoundedLogFileWriter::new_stdout(path, max_bytes).await?
} else {
BoundedLogFileWriter::new_stderr(path, max_bytes).await?
};
Ok(Some(writer))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -962,6 +962,8 @@ impl Runtime for ProcessRuntime {
context.max_stderr_bytes,
context.output_format,
context.cancel_token.clone(),
context.stdout_log_path.as_deref(),
context.stderr_log_path.as_deref(),
)
.await;
@@ -1144,6 +1146,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1179,6 +1183,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1214,6 +1220,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1305,6 +1313,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1364,6 +1374,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1443,6 +1455,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1485,6 +1499,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1532,6 +1548,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1583,6 +1601,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
@@ -1692,6 +1712,8 @@ mod tests {
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),

View File

@@ -12,10 +12,10 @@
//! 1. SIGTERM is sent to the process immediately
//! 2. After a 5-second grace period, SIGKILL is sent as a last resort
use super::{BoundedLogWriter, ExecutionResult, OutputFormat, RuntimeResult};
use super::{BoundedLogFileWriter, BoundedLogWriter, ExecutionResult, OutputFormat, RuntimeResult};
use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
@@ -59,6 +59,8 @@ pub async fn execute_streaming(
max_stderr_bytes,
output_format,
None,
None,
None,
)
.await
}
@@ -93,6 +95,8 @@ pub async fn execute_streaming_cancellable(
max_stderr_bytes: usize,
output_format: OutputFormat,
cancel_token: Option<CancellationToken>,
stdout_log_path: Option<&Path>,
stderr_log_path: Option<&Path>,
) -> RuntimeResult<ExecutionResult> {
let start = Instant::now();
@@ -130,6 +134,8 @@ pub async fn execute_streaming_cancellable(
// Create bounded writers
let mut stdout_writer = BoundedLogWriter::new_stdout(max_stdout_bytes);
let mut stderr_writer = BoundedLogWriter::new_stderr(max_stderr_bytes);
let mut stdout_file = open_live_log_file(stdout_log_path, max_stdout_bytes, true).await?;
let mut stderr_file = open_live_log_file(stderr_log_path, max_stderr_bytes, false).await?;
// Take stdout and stderr streams
let stdout = child.stdout.take().expect("stdout not captured");
@@ -150,6 +156,9 @@ pub async fn execute_streaming_cancellable(
if stdout_writer.write_all(&line).await.is_err() {
break;
}
if let Some(file) = stdout_file.as_mut() {
let _ = file.write_all(&line).await;
}
}
Err(_) => break,
}
@@ -167,6 +176,9 @@ pub async fn execute_streaming_cancellable(
if stderr_writer.write_all(&line).await.is_err() {
break;
}
if let Some(file) = stderr_file.as_mut() {
let _ = file.write_all(&line).await;
}
}
Err(_) => break,
}
@@ -351,6 +363,24 @@ pub async fn execute_streaming_cancellable(
})
}
async fn open_live_log_file(
path: Option<&Path>,
max_bytes: usize,
is_stdout: bool,
) -> io::Result<Option<BoundedLogFileWriter>> {
let Some(path) = path else {
return Ok(None);
};
let path: PathBuf = path.to_path_buf();
let writer = if is_stdout {
BoundedLogFileWriter::new_stdout(&path, max_bytes).await?
} else {
BoundedLogFileWriter::new_stderr(&path, max_bytes).await?
};
Ok(Some(writer))
}
/// Parse stdout content according to the specified output format.
fn configure_child_process(cmd: &mut Command) -> io::Result<()> {
#[cfg(unix)]
@@ -704,6 +734,8 @@ mod tests {
1024 * 1024,
OutputFormat::Text,
Some(cancel_token),
None,
None,
)
.await
.unwrap();

View File

@@ -1,819 +0,0 @@
//! Python Runtime Implementation
//!
//! Executes Python actions using subprocess execution.
use super::{
BoundedLogWriter, DependencyManagerRegistry, DependencySpec, ExecutionContext, ExecutionResult,
OutputFormat, Runtime, RuntimeError, RuntimeResult,
};
use async_trait::async_trait;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, info, warn};
/// Python runtime for executing Python scripts and functions
pub struct PythonRuntime {
/// Python interpreter path (fallback when no venv exists)
python_path: PathBuf,
/// Base directory for storing action code
work_dir: PathBuf,
/// Optional dependency manager registry for isolated environments
dependency_manager: Option<Arc<DependencyManagerRegistry>>,
}
impl PythonRuntime {
/// Create a new Python runtime
pub fn new() -> Self {
Self {
python_path: PathBuf::from("python3"),
work_dir: PathBuf::from("/tmp/attune/actions"),
dependency_manager: None,
}
}
/// Create a Python runtime with custom settings
pub fn with_config(python_path: PathBuf, work_dir: PathBuf) -> Self {
Self {
python_path,
work_dir,
dependency_manager: None,
}
}
/// Create a Python runtime with dependency manager support
pub fn with_dependency_manager(
python_path: PathBuf,
work_dir: PathBuf,
dependency_manager: Arc<DependencyManagerRegistry>,
) -> Self {
Self {
python_path,
work_dir,
dependency_manager: Some(dependency_manager),
}
}
/// Get the Python executable path to use for a given context
///
/// If the action has a pack_ref with dependencies, use the venv Python.
/// Otherwise, use the default Python interpreter.
async fn get_python_executable(&self, context: &ExecutionContext) -> RuntimeResult<PathBuf> {
// Check if we have a dependency manager and can extract pack_ref
if let Some(ref dep_mgr) = self.dependency_manager {
// Extract pack_ref from action_ref (format: "pack_ref.action_name")
if let Some(pack_ref) = context.action_ref.split('.').next() {
// Try to get the executable path for this pack
match dep_mgr.get_executable_path(pack_ref, "python").await {
Ok(python_path) => {
debug!(
"Using pack-specific Python from venv: {}",
python_path.display()
);
return Ok(python_path);
}
Err(e) => {
// Venv doesn't exist or failed - this is OK if pack has no dependencies
debug!(
"No venv found for pack {} ({}), using default Python",
pack_ref, e
);
}
}
}
}
// Fall back to default Python interpreter
debug!("Using default Python interpreter: {:?}", self.python_path);
Ok(self.python_path.clone())
}
/// Generate Python wrapper script that loads parameters and executes the action
fn generate_wrapper_script(&self, context: &ExecutionContext) -> RuntimeResult<String> {
let params_json = serde_json::to_string(&context.parameters)?;
// Use base64 encoding for code to avoid any quote/escape issues
let code_bytes = context.code.as_deref().unwrap_or("").as_bytes();
let code_base64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, code_bytes);
let wrapper = format!(
r#"#!/usr/bin/env python3
import sys
import json
import traceback
import base64
from pathlib import Path
# Global secrets storage (read from stdin, NOT from environment)
_attune_secrets = {{}}
def get_secret(name):
"""
Get a secret value by name.
Secrets are passed securely via stdin and are never exposed in
environment variables or process listings.
Args:
name (str): The name of the secret to retrieve
Returns:
str: The secret value, or None if not found
"""
return _attune_secrets.get(name)
def main():
global _attune_secrets
try:
# Read secrets from stdin FIRST (before executing action code)
# This prevents secrets from being visible in process environment
secrets_line = sys.stdin.readline().strip()
if secrets_line:
_attune_secrets = json.loads(secrets_line)
# Parse parameters
parameters = json.loads('''{}''')
# Decode action code from base64 (avoids quote/escape issues)
action_code = base64.b64decode('{}').decode('utf-8')
# Execute the code in a controlled namespace
# Include get_secret helper function
namespace = {{
'__name__': '__main__',
'parameters': parameters,
'get_secret': get_secret
}}
exec(action_code, namespace)
# Look for main function or run function
if '{}' in namespace:
result = namespace['{}'](**parameters)
elif 'run' in namespace:
result = namespace['run'](**parameters)
elif 'main' in namespace:
result = namespace['main'](**parameters)
else:
# No entry point found, return the namespace (only JSON-serializable values)
def is_json_serializable(obj):
"""Check if an object is JSON serializable"""
if obj is None:
return True
if isinstance(obj, (bool, int, float, str)):
return True
if isinstance(obj, (list, tuple)):
return all(is_json_serializable(item) for item in obj)
if isinstance(obj, dict):
return all(is_json_serializable(k) and is_json_serializable(v)
for k, v in obj.items())
return False
result = {{k: v for k, v in namespace.items()
if not k.startswith('__') and is_json_serializable(v)}}
# Output result as JSON
if result is not None:
print(json.dumps({{'result': result, 'status': 'success'}}))
else:
print(json.dumps({{'status': 'success'}}))
sys.exit(0)
except Exception as e:
error_info = {{
'status': 'error',
'error': str(e),
'error_type': type(e).__name__,
'traceback': traceback.format_exc()
}}
print(json.dumps(error_info), file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
"#,
params_json, code_base64, context.entry_point, context.entry_point
);
Ok(wrapper)
}
/// Execute with streaming and bounded log collection
async fn execute_with_streaming(
&self,
mut cmd: Command,
secrets: &std::collections::HashMap<String, String>,
timeout_secs: Option<u64>,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
output_format: OutputFormat,
) -> RuntimeResult<ExecutionResult> {
let start = Instant::now();
// Spawn process with piped I/O
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Write secrets to stdin
if let Some(mut stdin) = child.stdin.take() {
let secrets_json = serde_json::to_string(secrets)?;
stdin.write_all(secrets_json.as_bytes()).await?;
stdin.write_all(b"\n").await?;
drop(stdin);
}
// Create bounded writers
let mut stdout_writer = BoundedLogWriter::new_stdout(max_stdout_bytes);
let mut stderr_writer = BoundedLogWriter::new_stderr(max_stderr_bytes);
// Take stdout and stderr streams
let stdout = child.stdout.take().expect("stdout not captured");
let stderr = child.stderr.take().expect("stderr not captured");
// Create buffered readers
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
// Stream both outputs concurrently
let stdout_task = async {
let mut line = Vec::new();
loop {
line.clear();
match stdout_reader.read_until(b'\n', &mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
if stdout_writer.write_all(&line).await.is_err() {
break;
}
}
Err(_) => break,
}
}
stdout_writer
};
let stderr_task = async {
let mut line = Vec::new();
loop {
line.clear();
match stderr_reader.read_until(b'\n', &mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
if stderr_writer.write_all(&line).await.is_err() {
break;
}
}
Err(_) => break,
}
}
stderr_writer
};
// Wait for both streams and the process
let (stdout_writer, stderr_writer, wait_result) =
tokio::join!(stdout_task, stderr_task, async {
if let Some(timeout_secs) = timeout_secs {
timeout(std::time::Duration::from_secs(timeout_secs), child.wait()).await
} else {
Ok(child.wait().await)
}
});
let duration_ms = start.elapsed().as_millis() as u64;
// Handle timeout
let status = match wait_result {
Ok(Ok(status)) => status,
Ok(Err(e)) => {
return Err(RuntimeError::ProcessError(format!(
"Process wait failed: {}",
e
)));
}
Err(_) => {
return Ok(ExecutionResult {
exit_code: -1,
stdout: String::new(),
stderr: String::new(),
result: None,
duration_ms,
error: Some(format!(
"Execution timed out after {} seconds",
timeout_secs.unwrap()
)),
stdout_truncated: false,
stderr_truncated: false,
stdout_bytes_truncated: 0,
stderr_bytes_truncated: 0,
});
}
};
// Get results from bounded writers
let stdout_result = stdout_writer.into_result();
let stderr_result = stderr_writer.into_result();
let exit_code = status.code().unwrap_or(-1);
debug!(
"Python execution completed: exit_code={}, duration={}ms, stdout_truncated={}, stderr_truncated={}",
exit_code, duration_ms, stdout_result.truncated, stderr_result.truncated
);
// Parse result from stdout based on output_format
let result = if exit_code == 0 && !stdout_result.content.trim().is_empty() {
match output_format {
OutputFormat::Text => {
// No parsing - text output is captured in stdout field
None
}
OutputFormat::Json => {
// Try to parse full stdout as JSON first (handles multi-line JSON),
// then fall back to last line only (for scripts that log before output)
let trimmed = stdout_result.content.trim();
serde_json::from_str(trimmed).ok().or_else(|| {
trimmed
.lines()
.last()
.and_then(|line| serde_json::from_str(line).ok())
})
}
OutputFormat::Yaml => {
// Try to parse stdout as YAML
serde_yaml_ng::from_str(stdout_result.content.trim()).ok()
}
OutputFormat::Jsonl => {
// Parse each line as JSON and collect into array
let mut items = Vec::new();
for line in stdout_result.content.trim().lines() {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
items.push(value);
}
}
if items.is_empty() {
None
} else {
Some(serde_json::Value::Array(items))
}
}
}
} else {
None
};
Ok(ExecutionResult {
exit_code,
// Only populate stdout if result wasn't parsed (avoid duplication)
stdout: if result.is_some() {
String::new()
} else {
stdout_result.content.clone()
},
stderr: stderr_result.content.clone(),
result,
duration_ms,
error: if exit_code != 0 {
Some(stderr_result.content)
} else {
None
},
stdout_truncated: stdout_result.truncated,
stderr_truncated: stderr_result.truncated,
stdout_bytes_truncated: stdout_result.bytes_truncated,
stderr_bytes_truncated: stderr_result.bytes_truncated,
})
}
async fn execute_python_code(
&self,
script: String,
secrets: &std::collections::HashMap<String, String>,
env: &std::collections::HashMap<String, String>,
timeout_secs: Option<u64>,
python_path: PathBuf,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
output_format: OutputFormat,
) -> RuntimeResult<ExecutionResult> {
debug!(
"Executing Python script with {} secrets (passed via stdin)",
secrets.len()
);
// Build command
let mut cmd = Command::new(&python_path);
cmd.arg("-c").arg(&script);
// Add environment variables
for (key, value) in env {
cmd.env(key, value);
}
self.execute_with_streaming(
cmd,
secrets,
timeout_secs,
max_stdout_bytes,
max_stderr_bytes,
output_format,
)
.await
}
/// Execute Python script from file
async fn execute_python_file(
&self,
code_path: PathBuf,
secrets: &std::collections::HashMap<String, String>,
env: &std::collections::HashMap<String, String>,
timeout_secs: Option<u64>,
python_path: PathBuf,
max_stdout_bytes: usize,
max_stderr_bytes: usize,
output_format: OutputFormat,
) -> RuntimeResult<ExecutionResult> {
debug!(
"Executing Python file: {:?} with {} secrets",
code_path,
secrets.len()
);
// Build command
let mut cmd = Command::new(&python_path);
cmd.arg(&code_path);
// Add environment variables
for (key, value) in env {
cmd.env(key, value);
}
self.execute_with_streaming(
cmd,
secrets,
timeout_secs,
max_stdout_bytes,
max_stderr_bytes,
output_format,
)
.await
}
}
impl Default for PythonRuntime {
fn default() -> Self {
Self::new()
}
}
impl PythonRuntime {
/// Ensure pack dependencies are installed (called before execution if needed)
///
/// This is a helper method that can be called by the worker service to ensure
/// a pack's Python dependencies are set up before executing actions.
pub async fn ensure_pack_dependencies(
&self,
pack_ref: &str,
spec: &DependencySpec,
) -> RuntimeResult<()> {
if let Some(ref dep_mgr) = self.dependency_manager {
if spec.has_dependencies() {
info!(
"Ensuring Python dependencies for pack: {} ({} dependencies)",
pack_ref,
spec.dependencies.len()
);
dep_mgr
.ensure_environment(pack_ref, spec)
.await
.map_err(|e| {
RuntimeError::SetupError(format!(
"Failed to setup Python environment for {}: {}",
pack_ref, e
))
})?;
info!("Python dependencies ready for pack: {}", pack_ref);
} else {
debug!("Pack {} has no Python dependencies", pack_ref);
}
} else {
warn!("Dependency manager not configured, skipping dependency isolation");
}
Ok(())
}
}
#[async_trait]
impl Runtime for PythonRuntime {
fn name(&self) -> &str {
"python"
}
fn can_execute(&self, context: &ExecutionContext) -> bool {
// Check if action reference suggests Python
let is_python = context.action_ref.contains(".py")
|| context.entry_point.ends_with(".py")
|| context
.code_path
.as_ref()
.map(|p| p.extension().and_then(|e| e.to_str()) == Some("py"))
.unwrap_or(false);
is_python
}
async fn execute(&self, context: ExecutionContext) -> RuntimeResult<ExecutionResult> {
info!(
"Executing Python action: {} (execution_id: {})",
context.action_ref, context.execution_id
);
// Get the appropriate Python executable (venv or default)
let python_path = self.get_python_executable(&context).await?;
// If code_path is provided, execute the file directly
if let Some(code_path) = &context.code_path {
return self
.execute_python_file(
code_path.clone(),
&context.secrets,
&context.env,
context.timeout,
python_path,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.output_format,
)
.await;
}
// Otherwise, generate wrapper script and execute
let script = self.generate_wrapper_script(&context)?;
self.execute_python_code(
script,
&context.secrets,
&context.env,
context.timeout,
python_path,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.output_format,
)
.await
}
async fn setup(&self) -> RuntimeResult<()> {
info!("Setting up Python runtime");
// Ensure work directory exists
tokio::fs::create_dir_all(&self.work_dir)
.await
.map_err(|e| RuntimeError::SetupError(format!("Failed to create work dir: {}", e)))?;
// Verify Python is available
let output = Command::new(&self.python_path)
.arg("--version")
.output()
.await
.map_err(|e| {
RuntimeError::SetupError(format!(
"Python not found at {:?}: {}",
self.python_path, e
))
})?;
if !output.status.success() {
return Err(RuntimeError::SetupError(
"Python interpreter is not working".to_string(),
));
}
let version = String::from_utf8_lossy(&output.stdout);
info!("Python runtime ready: {}", version.trim());
Ok(())
}
async fn cleanup(&self) -> RuntimeResult<()> {
info!("Cleaning up Python runtime");
// Could clean up temporary files here
Ok(())
}
async fn validate(&self) -> RuntimeResult<()> {
debug!("Validating Python runtime");
// Check if Python is available
let output = Command::new(&self.python_path)
.arg("--version")
.output()
.await
.map_err(|e| RuntimeError::SetupError(format!("Python validation failed: {}", e)))?;
if !output.status.success() {
return Err(RuntimeError::SetupError(
"Python interpreter validation failed".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[tokio::test]
async fn test_python_runtime_simple() {
let runtime = PythonRuntime::new();
let context = ExecutionContext {
execution_id: 1,
action_ref: "test.simple".to_string(),
parameters: {
let mut map = HashMap::new();
map.insert("x".to_string(), serde_json::json!(5));
map.insert("y".to_string(), serde_json::json!(10));
map
},
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "run".to_string(),
code: Some(
r#"
def run(x, y):
return x + y
"#
.to_string(),
),
code_path: None,
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
parameter_delivery: attune_common::models::ParameterDelivery::default(),
parameter_format: attune_common::models::ParameterFormat::default(),
output_format: attune_common::models::OutputFormat::default(),
};
let result = runtime.execute(context).await.unwrap();
assert!(result.is_success());
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_python_runtime_timeout() {
let runtime = PythonRuntime::new();
let context = ExecutionContext {
execution_id: 2,
action_ref: "test.timeout".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(1),
working_dir: None,
entry_point: "run".to_string(),
code: Some(
r#"
import time
def run():
time.sleep(10)
return "done"
"#
.to_string(),
),
code_path: None,
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
parameter_delivery: attune_common::models::ParameterDelivery::default(),
parameter_format: attune_common::models::ParameterFormat::default(),
output_format: attune_common::models::OutputFormat::default(),
};
let result = runtime.execute(context).await.unwrap();
assert!(!result.is_success());
assert!(result.error.is_some());
let error_msg = result.error.unwrap();
assert!(error_msg.contains("timeout") || error_msg.contains("timed out"));
}
#[tokio::test]
async fn test_python_runtime_error() {
let runtime = PythonRuntime::new();
let context = ExecutionContext {
execution_id: 3,
action_ref: "test.error".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "run".to_string(),
code: Some(
r#"
def run():
raise ValueError("Test error")
"#
.to_string(),
),
code_path: None,
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
parameter_delivery: attune_common::models::ParameterDelivery::default(),
parameter_format: attune_common::models::ParameterFormat::default(),
output_format: attune_common::models::OutputFormat::default(),
};
let result = runtime.execute(context).await.unwrap();
assert!(!result.is_success());
assert!(result.error.is_some());
}
#[tokio::test]
#[ignore = "Pre-existing failure - secrets not being passed correctly"]
async fn test_python_runtime_with_secrets() {
let runtime = PythonRuntime::new();
let context = ExecutionContext {
execution_id: 4,
action_ref: "test.secrets".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: {
let mut s = HashMap::new();
s.insert("api_key".to_string(), "secret_key_12345".to_string());
s.insert("db_password".to_string(), "super_secret_pass".to_string());
s
},
timeout: Some(10),
working_dir: None,
entry_point: "run".to_string(),
code: Some(
r#"
def run():
# Access secrets via get_secret() helper
api_key = get_secret('api_key')
db_pass = get_secret('db_password')
missing = get_secret('nonexistent')
return {
'api_key': api_key,
'db_pass': db_pass,
'missing': missing
}
"#
.to_string(),
),
code_path: None,
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
parameter_delivery: attune_common::models::ParameterDelivery::default(),
parameter_format: attune_common::models::ParameterFormat::default(),
output_format: attune_common::models::OutputFormat::default(),
};
let result = runtime.execute(context).await.unwrap();
assert!(result.is_success());
assert_eq!(result.exit_code, 0);
// Verify secrets are accessible in action code
let result_data = result.result.unwrap();
let result_obj = result_data.get("result").unwrap();
assert_eq!(result_obj.get("api_key").unwrap(), "secret_key_12345");
assert_eq!(result_obj.get("db_pass").unwrap(), "super_secret_pass");
assert_eq!(result_obj.get("missing"), Some(&serde_json::Value::Null));
}
}

View File

@@ -171,6 +171,7 @@ impl WorkerService {
let registration = Arc::new(RwLock::new(WorkerRegistration::new(pool.clone(), &config)));
// Initialize artifact manager (legacy, for stdout/stderr log storage)
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Worker artifact/config directories come from trusted process configuration, not request data.
let artifact_base_dir = std::path::PathBuf::from(
config
.worker
@@ -184,6 +185,7 @@ impl WorkerService {
// Initialize artifacts directory for file-backed artifact storage (shared volume).
// Execution processes write artifact files here; the API serves them from the same path.
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact storage root is a trusted deployment configuration value.
let artifacts_dir = std::path::PathBuf::from(&config.artifacts_dir);
if let Err(e) = tokio::fs::create_dir_all(&artifacts_dir).await {
warn!(
@@ -198,7 +200,9 @@ impl WorkerService {
);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack/runtime roots are trusted deployment configuration values.
let packs_base_dir = std::path::PathBuf::from(&config.packs_base_dir);
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack/runtime roots are trusted deployment configuration values.
let runtime_envs_dir = std::path::PathBuf::from(&config.runtime_envs_dir);
// Determine which runtimes to register based on configuration

View File

@@ -86,6 +86,8 @@ fn make_context(action_ref: &str, entry_point: &str, runtime_name: &str) -> Exec
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),

View File

@@ -80,6 +80,8 @@ fn make_python_context(
selected_runtime_version: None,
max_stdout_bytes,
max_stderr_bytes,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -164,6 +166,8 @@ done
selected_runtime_version: None,
max_stdout_bytes: 400, // Small limit
max_stderr_bytes: 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -329,6 +333,8 @@ async fn test_shell_process_runtime_truncation() {
selected_runtime_version: None,
max_stdout_bytes: 500,
max_stderr_bytes: 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),

View File

@@ -112,6 +112,8 @@ print(json.dumps(result))
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::Json,
@@ -207,6 +209,8 @@ echo "SECURITY_PASS: Secrets not in inherited environment and accessible via mer
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -272,6 +276,8 @@ print(json.dumps({'secret_a': secrets.get('secret_a')}))
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::Json,
@@ -318,6 +324,8 @@ print(json.dumps({
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::Json,
@@ -373,6 +381,8 @@ print("ok")
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -425,6 +435,8 @@ fi
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -507,6 +519,8 @@ echo "PASS: No secrets in environment"
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::default(),
@@ -588,6 +602,8 @@ print(json.dumps({"leaked": leaked}))
selected_runtime_version: None,
max_stdout_bytes: 10 * 1024 * 1024,
max_stderr_bytes: 10 * 1024 * 1024,
stdout_log_path: None,
stderr_log_path: None,
parameter_delivery: attune_worker::runtime::ParameterDelivery::default(),
parameter_format: attune_worker::runtime::ParameterFormat::default(),
output_format: attune_worker::runtime::OutputFormat::Json,

View File

@@ -37,10 +37,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /auth/login) is passed through to the backend as-is.
location /auth/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -57,10 +56,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /api/packs?page=1) is passed through to the backend as-is.
location /api/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -78,10 +76,12 @@ server {
# e.g. /ws/events → /events
location /ws/ {
rewrite ^/ws/(.*) /$1 break;
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This WebSocket endpoint is intentionally public and must be reachable by clients.
proxy_pass $notifier_upstream;
# nosemgrep: generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling -- Upgrade handling is intentionally restricted to a fixed 'websocket' value for the public notifier endpoint.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Upgrade websocket;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -17,17 +17,20 @@ Environment Variables:
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import psycopg2
import psycopg2.extras
from psycopg2 import sql
import yaml
# Default configuration
DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/attune"
DEFAULT_PACKS_DIR = "./packs"
SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def generate_label(name: str) -> str:
@@ -64,8 +67,13 @@ class PackLoader:
self.conn.autocommit = False
# Set search_path to use the correct schema
if not SCHEMA_RE.match(self.schema):
raise ValueError(f"Invalid schema name: {self.schema}")
cursor = self.conn.cursor()
cursor.execute(f"SET search_path TO {self.schema}, public")
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query -- This uses psycopg2.sql.Identifier for safe identifier composition after schema-name validation.
cursor.execute(
sql.SQL("SET search_path TO {}, public").format(sql.Identifier(self.schema))
)
cursor.close()
self.conn.commit()
@@ -81,6 +89,16 @@ class PackLoader:
with open(file_path, "r") as f:
return yaml.safe_load(f)
def resolve_pack_relative_path(self, base_dir: Path, relative_path: str) -> Path:
"""Resolve a pack-owned relative path and reject traversal outside the pack."""
candidate = (base_dir / relative_path).resolve()
pack_root = self.pack_dir.resolve()
if not candidate.is_relative_to(pack_root):
raise ValueError(
f"Resolved path '{candidate}' escapes pack root '{pack_root}'"
)
return candidate
def upsert_pack(self) -> int:
"""Create or update the pack"""
print("\n→ Loading pack metadata...")
@@ -412,7 +430,7 @@ class PackLoader:
The database ID of the workflow_definition row, or None on failure.
"""
actions_dir = self.pack_dir / "actions"
full_path = actions_dir / workflow_file_path
full_path = self.resolve_pack_relative_path(actions_dir, workflow_file_path)
if not full_path.exists():
print(f" ⚠ Workflow file '{workflow_file_path}' not found at {full_path}")
return None

View File

@@ -37,10 +37,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /auth/login) is passed through to the backend as-is.
location /auth/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -57,10 +56,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /api/packs?page=1) is passed through to the backend as-is.
location /api/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -78,10 +76,12 @@ server {
# e.g. /ws/events → /events
location /ws/ {
rewrite ^/ws/(.*) /$1 break;
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This WebSocket endpoint is intentionally public and must be reachable by clients.
proxy_pass $notifier_upstream;
# nosemgrep: generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling -- Upgrade handling is intentionally restricted to a fixed 'websocket' value for the public notifier endpoint.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Upgrade websocket;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,400 @@
# Executor HA Horizontal Scaling Plan
## Overview
This plan describes the changes required to make the Attune executor service safe to run with multiple replicas. The current implementation already uses RabbitMQ competing consumers for most work distribution, but several correctness-critical parts of the executor still rely on process-local memory or non-atomic database updates. Those assumptions make the service unsafe under horizontal scaling, message replay, or partial failure.
The goal of this plan is to make executor behavior correct under:
- Multiple executor replicas running concurrently
- Message redelivery from RabbitMQ
- Replica crash/restart during scheduling or workflow advancement
- Background recovery loops running on more than one replica
## Problem Summary
The current executor has five HA blockers:
1. **Concurrency/FIFO control is process-local.**
`ExecutionQueueManager` stores active slots and waiting queues in memory. That means one replica can admit work while another replica receives the completion notification, causing slot release and queue advancement to fail.
2. **Execution scheduling has no atomic claim step.**
The scheduler reads an execution in `requested`, does policy checks and worker selection, then updates it later. Two replicas can both observe the same row as schedulable and both dispatch it.
3. **Workflow orchestration is not serialized.**
Workflow start and workflow advancement perform read-check-create-update sequences without a distributed lock or optimistic version check, so duplicate successor tasks can be created.
4. **Event/enforcement/inquiry handlers are not idempotent.**
Duplicate message delivery can create duplicate enforcements, duplicate executions, duplicate workflow starts, and duplicate inquiries.
5. **Timeout and DLQ handlers use non-conditional updates.**
Recovery loops can overwrite newer worker-owned state if the execution changes between the initial read and the final update.
## Goals
- Make execution scheduling single-winner under multiple executor replicas
- Make policy-based concurrency control and FIFO queueing shared across replicas
- Make workflow start and workflow advancement idempotent and serialized
- Make duplicate message delivery safe for executor-owned entities
- Make recovery loops safe to run on every replica
## Non-Goals
- Re-architecting RabbitMQ usage across the whole platform
- Replacing PostgreSQL with a dedicated distributed lock service
- Solving general worker autoscaling or Kubernetes deployment concerns in this phase
- Reworking unrelated executor features like retry policy design unless needed for HA correctness
## Design Principles
### Database is the source of truth
All coordination state that affects correctness must live in PostgreSQL, not in executor memory. In-memory caches and metrics are fine as optimization or observability layers, but correctness cannot depend on them.
### State transitions must be compare-and-swap
Any executor action that changes ownership or lifecycle state must use an atomic update that verifies the prior state in the same statement or transaction.
### Handlers must be idempotent on domain keys
RabbitMQ gives at-least-once delivery semantics. The executor must therefore tolerate duplicate delivery even when the same message is processed more than once or by different replicas.
### Workflow orchestration must be serialized per workflow
A workflow execution should have exactly one active mutator at a time when evaluating transitions and dispatching successor tasks.
## Proposed Implementation Phases
## Current Status
As of the current implementation state:
- Phase 1 is substantially implemented.
- Phases 2, 3, 4, and 5 are implemented.
Completed so far:
- Atomic `requested -> scheduling` claim support was added in `ExecutionRepository`.
- Scheduler state transitions for regular action dispatch were converted to conditional/CAS-style updates.
- Redelivered `execution.requested` messages for stale `scheduling` rows are now retried/reclaimed instead of being silently acknowledged away.
- Shared concurrency/FIFO coordination now uses durable PostgreSQL admission tables for action/group slot ownership and queued execution ordering.
- `ExecutionQueueManager` now acts as a thin API-compatible facade over the DB-backed admission path when constructed with a pool.
- Slot release, queued removal, and rollback/restore flows now operate against shared DB state rather than process-local memory.
- `queue_stats` remains derived telemetry, but it is now refreshed transactionally from the shared admission state.
- Workflow start is now idempotent at the parent workflow state level via `workflow_execution(execution)` uniqueness plus repository create-or-get behavior.
- Workflow advancement now runs under a per-workflow PostgreSQL advisory lock, row-locks `workflow_execution` with `SELECT ... FOR UPDATE`, and performs serialized mutation inside an explicit SQL transaction.
- Durable workflow child dispatch dedupe is now enforced with the `workflow_task_dispatch` coordination table and repository create-or-get helpers.
- `execution` and `enforcement` were switched from Timescale hypertables back to normal PostgreSQL tables to remove HA/idempotency friction around foreign keys and unique constraints. `event` remains a hypertable, and history tables remain Timescale-backed.
- Direct uniqueness/idempotency invariants were added for `enforcement(rule, event)`, top-level `execution(enforcement)`, and `inquiry(execution)`.
- Event, enforcement, and inquiry handlers were updated to use create-or-get flows and conditional status transitions so duplicate delivery becomes safe.
- Timeout and DLQ recovery loops now use conditional state transitions and only emit side effects when the guarded update succeeds.
Partially complete / still open:
- HA-focused integration and failure-injection coverage still needs to be expanded around the new invariants and recovery behavior.
- The new migrations and DB-backed FIFO tests still need end-to-end validation against a real Postgres/Timescale environment.
## Phase 1: Atomic Execution Claiming
### Objective
Ensure only one executor replica can claim a `requested` execution for scheduling.
### Changes
**`crates/common/src/repositories/execution.rs`**
- Add a repository method for atomic status transition, for example:
- `claim_for_scheduling(id, expected_status, executor_id) -> Option<Execution>`
- or a more general compare-and-swap helper
- Implement the claim as a single `UPDATE ... WHERE id = $1 AND status = 'requested' RETURNING ...`
- Optionally persist the claiming replica identity in `execution.executor` for debugging and traceability
**`crates/executor/src/scheduler.rs`**
- Claim the execution before policy enforcement, worker selection, workflow start, or any side effects
- Use `scheduling` as the claimed intermediate state
- If the claim returns no row, treat the execution as already claimed/handled and acknowledge the message
- Convert all later scheduler writes to conditional transitions from claimed state
### Success Criteria
- Two schedulers racing on the same execution cannot both dispatch it
- Redelivered `execution.requested` messages become harmless no-ops after the first successful claim
### Status
Implemented for regular action scheduling, with additional stale-claim recovery for redelivered `execution.requested` messages. The remaining gap for this area is broader integration with the still-pending shared admission/queueing work in Phase 2.
## Phase 2: Shared Concurrency Control and FIFO Queueing
### Objective
Replace the in-memory `ExecutionQueueManager` as the source of truth for concurrency slots and waiting order.
### Changes
**New schema**
Add database-backed coordination tables, likely along these lines:
- `execution_admission_slot`
- active slot ownership for an action/group key
- `execution_admission_queue`
- ordered waiting executions for an action/group key
Alternative naming is fine, but the design needs to support:
- Action-level concurrency limits
- Parameter-group concurrency keys
- FIFO ordering within each action/group
- Deterministic advancement when a slot is released
**`crates/executor/src/policy_enforcer.rs`**
- Replace `ExecutionQueueManager` slot acquisition with DB-backed admission logic
- Keep existing policy semantics:
- `enqueue`
- `cancel`
- parameter-based concurrency grouping
**`crates/executor/src/completion_listener.rs`**
- Release the shared slot transactionally on completion
- Select and wake the next queued execution in the same transaction
- Republish only after the DB state is committed
**`crates/executor/src/queue_manager.rs`**
- Either remove it entirely or reduce it to a thin adapter over DB-backed coordination
- Do not keep active slot ownership in process-local `DashMap`
**`crates/common/src/repositories/queue_stats.rs`**
- Keep `queue_stats` as derived telemetry only
- Do not rely on it for correctness
### Success Criteria
- Completion processed by a different executor replica still releases the correct slot
- FIFO ordering holds across multiple executor replicas
- Restarting an executor does not lose queue ownership state
### Status
Implemented.
Completed:
- Shared admission state now lives in PostgreSQL via durable action/group queue rows and execution entry rows.
- Action-level concurrency limits and parameter-group concurrency keys are enforced against that shared admission state.
- FIFO ordering is determined by durable queued-entry order rather than process-local memory.
- Completion-time slot release promotes the next queued execution inside the same DB transaction.
- Rollback helpers can restore released slots or removed queued entries if republish/cleanup fails after the DB mutation.
- `ExecutionQueueManager` remains as a facade for the existing scheduler/policy code paths, but it no longer acts as the correctness source of truth when running with a DB pool.
## Phase 3: Workflow Start Idempotency and Serialized Advancement
### Objective
Ensure workflow orchestration is safe under concurrent replicas and duplicate completion messages.
### Changes
**Migration**
Add a uniqueness constraint to guarantee one workflow state row per parent execution:
```sql
ALTER TABLE workflow_execution
ADD CONSTRAINT uq_workflow_execution_execution UNIQUE (execution);
```
**`crates/executor/src/scheduler.rs`**
- Change workflow start to be idempotent:
- either `INSERT ... ON CONFLICT ...`
- or claim parent execution first and only create workflow state once
- When advancing a workflow:
- wrap read/decide/write logic in a transaction
- lock the `workflow_execution` row with `SELECT ... FOR UPDATE`
- or use advisory locks keyed by workflow execution id
**Successor dispatch dedupe**
Add a durable uniqueness guarantee for child task dispatch, for example:
- one unique key for regular workflow tasks:
- `(workflow_execution_id, task_name, task_index IS NULL)`
- one unique key for `with_items` children:
- `(workflow_execution_id, task_name, task_index)`
This may be implemented with explicit columns or a dedupe table if indexing the current JSONB layout is awkward.
**Repository support**
- Add workflow repository helpers that support transactional locking and conditional updates
- Avoid blind overwrite of `completed_tasks`, `failed_tasks`, and `variables` outside a serialized transaction
### Success Criteria
- Starting the same workflow twice cannot create two `workflow_execution` rows
- Duplicate `execution.completed` delivery for a workflow child cannot create duplicate successor executions
- Two executor replicas cannot concurrently mutate the same workflow state
### Status
Implemented.
Completed:
- `workflow_execution(execution)` uniqueness is part of the workflow schema and workflow start uses create-or-get semantics.
- Workflow parent executions are claimed before orchestration starts.
- Workflow advancement now runs under a per-workflow PostgreSQL advisory lock held on the same DB connection that performs the serialized advancement work.
- The serialized workflow path is wrapped in an explicit SQL transaction.
- `workflow_execution` is row-locked with `SELECT ... FOR UPDATE` before mutation.
- Successor/child dispatch dedupe is enforced with the durable `workflow_task_dispatch` table keyed by `(workflow_execution, task_name, COALESCE(task_index, -1))`.
- Child `ExecutionRequested` messages are staged and published only after the workflow transaction commits.
## Phase 4: Idempotent Event, Enforcement, and Inquiry Handling
### Objective
Make duplicate delivery safe for all earlier and later executor-owned side effects.
### Changes
**Enforcement dedupe**
Add a uniqueness rule so one event/rule pair produces at most one enforcement when `event` is present.
Example:
```sql
CREATE UNIQUE INDEX uq_enforcement_rule_event
ON enforcement(rule, event)
WHERE event IS NOT NULL;
```
**`crates/executor/src/event_processor.rs`**
- Use upsert-or-ignore semantics for enforcement creation
- Treat uniqueness conflicts as idempotent success
**`crates/executor/src/enforcement_processor.rs`**
- Check current enforcement status before creating an execution
- Add a durable relation that prevents an enforcement from creating more than one top-level execution
- Options:
- unique partial index on `execution(enforcement)` for top-level executions
- or a separate coordination record
**Inquiry dedupe**
- Prevent duplicate inquiry creation per execution result/completion path
- Add a unique invariant such as one active inquiry per execution, if that matches product semantics
- Update completion handling to tolerate duplicate `execution.completed`
### Success Criteria
- Duplicate `event.created` does not create duplicate enforcements
- Duplicate `enforcement.created` does not create duplicate executions
- Duplicate completion handling does not create duplicate inquiries
### Status
Implemented.
Completed:
- `enforcement(rule, event)` uniqueness is enforced directly with a partial unique index when both keys are present.
- Top-level execution creation is deduped with a unique invariant on `execution(enforcement)` where `parent IS NULL`.
- Inquiry creation is deduped with a unique invariant on `inquiry(execution)`.
- `event_processor` now uses create-or-get enforcement handling and only republishes when the persisted enforcement still needs processing.
- `enforcement_processor` now skips duplicate non-`created` enforcements, creates or reuses the top-level execution, and conditionally resolves enforcement state.
- `inquiry_handler` now uses create-or-get inquiry handling and only emits `InquiryCreated` when the inquiry was actually created.
## Phase 5: Safe Recovery Loops
### Objective
Make timeout and DLQ processing safe under races and multiple replicas.
### Changes
**`crates/executor/src/timeout_monitor.rs`**
- Replace unconditional updates with conditional state transitions:
- `UPDATE execution SET ... WHERE id = $1 AND status = 'scheduled' ... RETURNING ...`
- Only publish completion side effects when a row was actually updated
- Consider including `updated < cutoff` in the same update statement
**`crates/executor/src/dead_letter_handler.rs`**
- Change failure transition to conditional update based on current state
- Do not overwrite executions that have already moved to `running` or terminal state
- Only emit side effects when the row transition succeeded
**`crates/executor/src/service.rs`**
- It is acceptable for these loops to run on every replica once updates are conditional
- Optional future optimization: leader election for janitor loops to reduce duplicate scans and log noise
### Success Criteria
- Timeout monitor cannot fail an execution that has already moved to `running`
- DLQ handler cannot overwrite newer state
- Running multiple timeout monitors produces no conflicting state transitions
### Status
Implemented.
Completed:
- Timeout failure now uses a conditional transition that only succeeds when the execution is still `scheduled` and still older than the timeout cutoff.
- Timeout-driven completion side effects are only published when that guarded update succeeds.
- DLQ handling now treats messages as stale unless the execution is still exactly `scheduled`.
- DLQ failure transitions now use conditional status updates and no longer overwrite newer `running` or terminal state.
## Testing Plan
Add focused HA tests after the repository and scheduler primitives are in place.
### Repository tests
- Compare-and-swap execution claim succeeds exactly once
- Conditional timeout/DLQ transition updates exactly one row or zero rows as expected
- Workflow uniqueness constraint prevents duplicate workflow state rows
### Executor integration tests
- Two scheduler instances processing the same `execution.requested` message only dispatch once
- Completion consumed by a different executor replica still advances the shared queue
- Duplicate workflow child completion does not create duplicate successor tasks
- Duplicate `event.created` and `enforcement.created` messages do not create duplicate downstream records
### Failure-injection tests
- Executor crashes after claiming but before publish
- Executor crashes after slot release but before republish
- Duplicate `execution.completed` delivery after successful workflow advancement
## Recommended Execution Order for Next Session
1. Add more HA-focused integration tests for duplicate delivery, cross-replica completion, and recovery rollback paths
2. Add failure-injection tests for crash/replay scenarios around `scheduling` reclaim, workflow advancement, and post-commit publish paths
3. Validate the new migrations and DB-backed FIFO behavior end-to-end against a real Postgres/Timescale environment
4. Consider a small follow-up cleanup pass to reduce or remove the in-memory fallback code in `ExecutionQueueManager` once the DB path is fully baked
## Expected Outcome
After this plan is implemented, the executor should be able to scale horizontally without relying on singleton behavior. Multiple executor replicas should be able to process work concurrently while preserving:
- exactly-once scheduling semantics at the execution state level
- shared concurrency limits and FIFO behavior
- correct workflow orchestration
- safe replay handling
- safe recovery behavior during failures and redelivery
At the current state, the core executor HA phases are implemented. The remaining work is confidence-building: failure-injection coverage, multi-replica integration testing, and end-to-end migration validation in a live database environment.

View File

@@ -201,6 +201,9 @@ CREATE INDEX idx_enforcement_rule_status ON enforcement(rule, status);
CREATE INDEX idx_enforcement_event_status ON enforcement(event, status);
CREATE INDEX idx_enforcement_payload_gin ON enforcement USING GIN (payload);
CREATE INDEX idx_enforcement_conditions_gin ON enforcement USING GIN (conditions);
CREATE UNIQUE INDEX uq_enforcement_rule_event
ON enforcement (rule, event)
WHERE rule IS NOT NULL AND event IS NOT NULL;
-- Comments
COMMENT ON TABLE enforcement IS 'Enforcements represent rule triggering by events';

View File

@@ -4,13 +4,8 @@
-- Consolidates former migrations: 000006 (execution_system), 000008
-- (worker_notification), 000014 (worker_table), and 20260209 (phase3).
--
-- NOTE: The execution table is converted to a TimescaleDB hypertable in
-- migration 000009. Hypertables cannot be the target of FK constraints,
-- so columns referencing execution (inquiry.execution, workflow_execution.execution)
-- are plain BIGINT with no FK. Similarly, columns ON the execution table that
-- would self-reference or reference other hypertables (parent, enforcement,
-- original_execution) are plain BIGINT. The action and executor FKs are also
-- omitted since they would need to be dropped during hypertable conversion.
-- NOTE: `execution` remains a regular PostgreSQL table. Time-series
-- audit and analytics are handled by `execution_history`.
-- Version: 20250101000005
-- ============================================================================
@@ -19,27 +14,27 @@
CREATE TABLE execution (
id BIGSERIAL PRIMARY KEY,
action BIGINT, -- references action(id); no FK because execution becomes a hypertable
action BIGINT,
action_ref TEXT NOT NULL,
config JSONB,
env_vars JSONB,
parent BIGINT, -- self-reference; no FK because execution becomes a hypertable
enforcement BIGINT, -- references enforcement(id); no FK (both are hypertables)
executor BIGINT, -- references identity(id); no FK because execution becomes a hypertable
worker BIGINT, -- references worker(id); no FK because execution becomes a hypertable
parent BIGINT,
enforcement BIGINT,
executor BIGINT,
worker BIGINT,
status execution_status_enum NOT NULL DEFAULT 'requested',
result JSONB,
started_at TIMESTAMPTZ, -- set when execution transitions to 'running'
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_workflow BOOLEAN DEFAULT false NOT NULL,
workflow_def BIGINT, -- references workflow_definition(id); no FK because execution becomes a hypertable
workflow_def BIGINT,
workflow_task JSONB,
-- Retry tracking (baked in from phase 3)
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER,
retry_reason TEXT,
original_execution BIGINT, -- self-reference; no FK because execution becomes a hypertable
original_execution BIGINT,
updated TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -64,6 +59,11 @@ CREATE INDEX idx_execution_result_gin ON execution USING GIN (result);
CREATE INDEX idx_execution_env_vars_gin ON execution USING GIN (env_vars);
CREATE INDEX idx_execution_original_execution ON execution(original_execution) WHERE original_execution IS NOT NULL;
CREATE INDEX idx_execution_status_retry ON execution(status, retry_count) WHERE status = 'failed' AND retry_count < COALESCE(max_retries, 0);
CREATE UNIQUE INDEX uq_execution_top_level_enforcement
ON execution (enforcement)
WHERE enforcement IS NOT NULL
AND parent IS NULL
AND (config IS NULL OR NOT (config ? 'retry_of'));
-- Trigger
CREATE TRIGGER update_execution_updated
@@ -77,10 +77,10 @@ COMMENT ON COLUMN execution.action IS 'Action being executed (may be null if act
COMMENT ON COLUMN execution.action_ref IS 'Action reference (preserved even if action deleted)';
COMMENT ON COLUMN execution.config IS 'Snapshot of action configuration at execution time';
COMMENT ON COLUMN execution.env_vars IS 'Environment variables for this execution as key-value pairs (string -> string). These are set in the execution environment and are separate from action parameters. Used for execution context, configuration, and non-sensitive metadata.';
COMMENT ON COLUMN execution.parent IS 'Parent execution ID for workflow hierarchies (no FK — execution is a hypertable)';
COMMENT ON COLUMN execution.enforcement IS 'Enforcement that triggered this execution (no FK — both are hypertables)';
COMMENT ON COLUMN execution.executor IS 'Identity that initiated the execution (no FK — execution is a hypertable)';
COMMENT ON COLUMN execution.worker IS 'Assigned worker handling this execution (no FK — execution is a hypertable)';
COMMENT ON COLUMN execution.parent IS 'Parent execution ID for workflow hierarchies';
COMMENT ON COLUMN execution.enforcement IS 'Enforcement that triggered this execution';
COMMENT ON COLUMN execution.executor IS 'Identity that initiated the execution';
COMMENT ON COLUMN execution.worker IS 'Assigned worker handling this execution';
COMMENT ON COLUMN execution.status IS 'Current execution lifecycle status';
COMMENT ON COLUMN execution.result IS 'Execution output/results';
COMMENT ON COLUMN execution.retry_count IS 'Current retry attempt number (0 = first attempt, 1 = first retry, etc.)';
@@ -96,7 +96,7 @@ COMMENT ON COLUMN execution.original_execution IS 'ID of the original execution
CREATE TABLE inquiry (
id BIGSERIAL PRIMARY KEY,
execution BIGINT NOT NULL, -- references execution(id); no FK because execution is a hypertable
execution BIGINT NOT NULL,
prompt TEXT NOT NULL,
response_schema JSONB,
assigned_to BIGINT REFERENCES identity(id) ON DELETE SET NULL,
@@ -109,7 +109,7 @@ CREATE TABLE inquiry (
);
-- Indexes
CREATE INDEX idx_inquiry_execution ON inquiry(execution);
CREATE UNIQUE INDEX uq_inquiry_execution ON inquiry(execution) WHERE execution IS NOT NULL;
CREATE INDEX idx_inquiry_assigned_to ON inquiry(assigned_to);
CREATE INDEX idx_inquiry_status ON inquiry(status);
CREATE INDEX idx_inquiry_timeout_at ON inquiry(timeout_at) WHERE timeout_at IS NOT NULL;
@@ -127,7 +127,31 @@ CREATE TRIGGER update_inquiry_updated
-- Comments
COMMENT ON TABLE inquiry IS 'Inquiries enable human-in-the-loop workflows with async user interactions';
COMMENT ON COLUMN inquiry.execution IS 'Execution that is waiting on this inquiry (no FK — execution is a hypertable)';
COMMENT ON COLUMN inquiry.execution IS 'Execution that is waiting on this inquiry';
ALTER TABLE execution
ADD CONSTRAINT execution_action_fkey
FOREIGN KEY (action) REFERENCES action(id) ON DELETE SET NULL;
ALTER TABLE execution
ADD CONSTRAINT execution_parent_fkey
FOREIGN KEY (parent) REFERENCES execution(id) ON DELETE SET NULL;
ALTER TABLE execution
ADD CONSTRAINT execution_original_execution_fkey
FOREIGN KEY (original_execution) REFERENCES execution(id) ON DELETE SET NULL;
ALTER TABLE execution
ADD CONSTRAINT execution_enforcement_fkey
FOREIGN KEY (enforcement) REFERENCES enforcement(id) ON DELETE SET NULL;
ALTER TABLE execution
ADD CONSTRAINT execution_executor_fkey
FOREIGN KEY (executor) REFERENCES identity(id) ON DELETE SET NULL;
ALTER TABLE inquiry
ADD CONSTRAINT inquiry_execution_fkey
FOREIGN KEY (execution) REFERENCES execution(id) ON DELETE CASCADE;
COMMENT ON COLUMN inquiry.prompt IS 'Question or prompt text for the user';
COMMENT ON COLUMN inquiry.response_schema IS 'JSON schema defining expected response format';
COMMENT ON COLUMN inquiry.assigned_to IS 'Identity who should respond to this inquiry';
@@ -261,6 +285,10 @@ COMMENT ON COLUMN worker.capabilities IS 'Worker capabilities (e.g., max_concurr
COMMENT ON COLUMN worker.meta IS 'Additional worker metadata';
COMMENT ON COLUMN worker.last_heartbeat IS 'Timestamp of last heartbeat from worker';
ALTER TABLE execution
ADD CONSTRAINT execution_worker_fkey
FOREIGN KEY (worker) REFERENCES worker(id) ON DELETE SET NULL;
-- ============================================================================
-- NOTIFICATION TABLE
-- ============================================================================

View File

@@ -1,13 +1,11 @@
-- Migration: Workflow System
-- Description: Creates workflow_definition and workflow_execution tables
-- Description: Creates workflow_definition, workflow_execution, and
-- workflow_task_dispatch tables
-- (workflow_task_execution consolidated into execution.workflow_task JSONB)
--
-- NOTE: The execution table is converted to a TimescaleDB hypertable in
-- migration 000009. Hypertables cannot be the target of FK constraints,
-- so workflow_execution.execution is a plain BIGINT with no FK.
-- execution.workflow_def also has no FK (added as plain BIGINT in 000005)
-- since execution is a hypertable and FKs from hypertables are only
-- supported for simple cases — we omit it for consistency.
-- NOTE: `execution` remains a regular PostgreSQL table, so
-- workflow_execution.execution, workflow_task_dispatch.execution_id,
-- and execution.workflow_def use normal foreign keys.
-- Version: 20250101000006
-- ============================================================================
@@ -54,7 +52,7 @@ COMMENT ON COLUMN workflow_definition.out_schema IS 'JSON schema for workflow ou
CREATE TABLE workflow_execution (
id BIGSERIAL PRIMARY KEY,
execution BIGINT NOT NULL, -- references execution(id); no FK because execution is a hypertable
execution BIGINT NOT NULL REFERENCES execution(id) ON DELETE CASCADE,
workflow_def BIGINT NOT NULL REFERENCES workflow_definition(id) ON DELETE CASCADE,
current_tasks TEXT[] DEFAULT '{}',
completed_tasks TEXT[] DEFAULT '{}',
@@ -67,11 +65,11 @@ CREATE TABLE workflow_execution (
paused BOOLEAN DEFAULT false NOT NULL,
pause_reason TEXT,
created TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated TIMESTAMPTZ DEFAULT NOW() NOT NULL
updated TIMESTAMPTZ DEFAULT NOW() NOT NULL,
CONSTRAINT uq_workflow_execution_execution UNIQUE (execution)
);
-- Indexes
CREATE INDEX idx_workflow_exec_execution ON workflow_execution(execution);
CREATE INDEX idx_workflow_exec_workflow_def ON workflow_execution(workflow_def);
CREATE INDEX idx_workflow_exec_status ON workflow_execution(status);
CREATE INDEX idx_workflow_exec_paused ON workflow_execution(paused) WHERE paused = true;
@@ -83,12 +81,51 @@ CREATE TRIGGER update_workflow_execution_updated
EXECUTE FUNCTION update_updated_column();
-- Comments
COMMENT ON TABLE workflow_execution IS 'Runtime state tracking for workflow executions. execution column has no FK — execution is a hypertable.';
COMMENT ON TABLE workflow_execution IS 'Runtime state tracking for workflow executions.';
COMMENT ON COLUMN workflow_execution.variables IS 'Workflow-scoped variables, updated via publish directives';
COMMENT ON COLUMN workflow_execution.task_graph IS 'Execution graph with dependencies and transitions';
COMMENT ON COLUMN workflow_execution.current_tasks IS 'Array of task names currently executing';
COMMENT ON COLUMN workflow_execution.paused IS 'True if workflow execution is paused (can be resumed)';
-- ============================================================================
-- WORKFLOW TASK DISPATCH TABLE
-- ============================================================================
CREATE TABLE workflow_task_dispatch (
id BIGSERIAL PRIMARY KEY,
workflow_execution BIGINT NOT NULL REFERENCES workflow_execution(id) ON DELETE CASCADE,
task_name TEXT NOT NULL,
task_index INT,
execution_id BIGINT,
created TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
CREATE UNIQUE INDEX uq_workflow_task_dispatch_identity
ON workflow_task_dispatch (
workflow_execution,
task_name,
COALESCE(task_index, -1)
);
CREATE INDEX idx_workflow_task_dispatch_execution_id
ON workflow_task_dispatch (execution_id)
WHERE execution_id IS NOT NULL;
CREATE TRIGGER update_workflow_task_dispatch_updated
BEFORE UPDATE ON workflow_task_dispatch
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON TABLE workflow_task_dispatch IS
'Durable dedupe/ownership records for workflow child execution dispatch';
COMMENT ON COLUMN workflow_task_dispatch.execution_id IS
'Associated execution.id';
ALTER TABLE workflow_task_dispatch
ADD CONSTRAINT workflow_task_dispatch_execution_id_fkey
FOREIGN KEY (execution_id) REFERENCES execution(id) ON DELETE CASCADE;
-- ============================================================================
-- MODIFY ACTION TABLE - Add Workflow Support
-- ============================================================================
@@ -100,9 +137,9 @@ CREATE INDEX idx_action_workflow_def ON action(workflow_def);
COMMENT ON COLUMN action.workflow_def IS 'Reference to workflow definition (non-null means this action is a workflow)';
-- NOTE: execution.workflow_def has no FK constraint because execution is a
-- TimescaleDB hypertable (converted in migration 000009). The column was
-- created as a plain BIGINT in migration 000005.
ALTER TABLE execution
ADD CONSTRAINT execution_workflow_def_fkey
FOREIGN KEY (workflow_def) REFERENCES workflow_definition(id) ON DELETE SET NULL;
-- ============================================================================
-- WORKFLOW VIEWS

View File

@@ -1,6 +1,6 @@
-- Migration: Supporting Systems
-- Description: Creates keys, artifacts, queue_stats, pack_environment, pack_testing,
-- and webhook function tables.
-- Description: Creates keys, artifacts, queue_stats, execution_admission,
-- pack_environment, pack_testing, and webhook function tables.
-- Consolidates former migrations: 000009 (keys_artifacts), 000010 (webhook_system),
-- 000011 (pack_environments), and 000012 (pack_testing).
-- Version: 20250101000007
@@ -206,6 +206,76 @@ COMMENT ON COLUMN queue_stats.total_enqueued IS 'Total executions enqueued since
COMMENT ON COLUMN queue_stats.total_completed IS 'Total executions completed since queue creation';
COMMENT ON COLUMN queue_stats.last_updated IS 'Timestamp of last statistics update';
-- ============================================================================
-- EXECUTION ADMISSION TABLES
-- ============================================================================
CREATE TABLE execution_admission_state (
id BIGSERIAL PRIMARY KEY,
action_id BIGINT NOT NULL REFERENCES action(id) ON DELETE CASCADE,
group_key TEXT,
group_key_normalized TEXT GENERATED ALWAYS AS (COALESCE(group_key, '')) STORED,
max_concurrent INTEGER NOT NULL,
next_queue_order BIGINT NOT NULL DEFAULT 1,
total_enqueued BIGINT NOT NULL DEFAULT 0,
total_completed BIGINT NOT NULL DEFAULT 0,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_execution_admission_state_identity
UNIQUE (action_id, group_key_normalized)
);
CREATE TABLE execution_admission_entry (
id BIGSERIAL PRIMARY KEY,
state_id BIGINT NOT NULL REFERENCES execution_admission_state(id) ON DELETE CASCADE,
execution_id BIGINT NOT NULL UNIQUE REFERENCES execution(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('active', 'queued')),
queue_order BIGINT NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
activated_at TIMESTAMPTZ,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_execution_admission_state_action
ON execution_admission_state (action_id);
CREATE INDEX idx_execution_admission_entry_state_status_queue
ON execution_admission_entry (state_id, status, queue_order);
CREATE INDEX idx_execution_admission_entry_execution
ON execution_admission_entry (execution_id);
CREATE TRIGGER update_execution_admission_state_updated
BEFORE UPDATE ON execution_admission_state
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
CREATE TRIGGER update_execution_admission_entry_updated
BEFORE UPDATE ON execution_admission_entry
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON TABLE execution_admission_state IS
'Shared admission state per action/group for executor concurrency and FIFO coordination';
COMMENT ON COLUMN execution_admission_state.group_key IS
'Optional parameter-derived concurrency grouping key';
COMMENT ON COLUMN execution_admission_state.max_concurrent IS
'Current concurrency limit for this action/group queue';
COMMENT ON COLUMN execution_admission_state.next_queue_order IS
'Monotonic sequence used to preserve exact FIFO order for queued executions';
COMMENT ON COLUMN execution_admission_state.total_enqueued IS
'Cumulative number of executions admitted into this queue';
COMMENT ON COLUMN execution_admission_state.total_completed IS
'Cumulative number of active executions released from this queue';
COMMENT ON TABLE execution_admission_entry IS
'Active slot ownership and queued executions for shared admission control';
COMMENT ON COLUMN execution_admission_entry.status IS
'active rows own a concurrency slot; queued rows wait in FIFO order';
COMMENT ON COLUMN execution_admission_entry.queue_order IS
'Durable FIFO position within an action/group queue';
-- ============================================================================
-- PACK ENVIRONMENT TABLE
-- ============================================================================

View File

@@ -143,52 +143,8 @@ SELECT create_hypertable('event', 'created',
COMMENT ON TABLE event IS 'Events are instances of triggers firing (TimescaleDB hypertable partitioned on created)';
-- ============================================================================
-- CONVERT ENFORCEMENT TABLE TO HYPERTABLE
-- ============================================================================
-- Enforcements are created and then updated exactly once (status changes from
-- `created` to `processed` or `disabled` within ~1 second). This single update
-- happens well before the 7-day compression window, so UPDATE on uncompressed
-- chunks works without issues.
--
-- No FK constraints reference enforcement(id) — execution.enforcement was
-- created as a plain BIGINT in migration 000005.
-- ----------------------------------------------------------------------------
ALTER TABLE enforcement DROP CONSTRAINT enforcement_pkey;
ALTER TABLE enforcement ADD PRIMARY KEY (id, created);
SELECT create_hypertable('enforcement', 'created',
chunk_time_interval => INTERVAL '1 day',
migrate_data => true);
COMMENT ON TABLE enforcement IS 'Enforcements represent rule triggering by events (TimescaleDB hypertable partitioned on created)';
-- ============================================================================
-- CONVERT EXECUTION TABLE TO HYPERTABLE
-- ============================================================================
-- Executions are updated ~4 times during their lifecycle (requested → scheduled
-- → running → completed/failed), completing within at most ~1 day — well before
-- the 7-day compression window. The `updated` column and its BEFORE UPDATE
-- trigger are preserved (used by timeout monitor and UI).
--
-- No FK constraints reference execution(id) — inquiry.execution,
-- workflow_execution.execution, execution.parent, and execution.original_execution
-- were all created as plain BIGINT columns in migrations 000005 and 000006.
--
-- The existing execution_history hypertable and its trigger are preserved —
-- they track field-level diffs of each update, which remains valuable for
-- a mutable table.
-- ----------------------------------------------------------------------------
ALTER TABLE execution DROP CONSTRAINT execution_pkey;
ALTER TABLE execution ADD PRIMARY KEY (id, created);
SELECT create_hypertable('execution', 'created',
chunk_time_interval => INTERVAL '1 day',
migrate_data => true);
COMMENT ON TABLE execution IS 'Executions represent action runs with workflow support (TimescaleDB hypertable partitioned on created). Updated ~4 times during lifecycle, completing within ~1 day (well before 7-day compression window).';
COMMENT ON TABLE enforcement IS 'Enforcements represent rule triggering by events';
COMMENT ON TABLE execution IS 'Executions represent action runs with workflow support. History and analytics are stored in execution_history.';
-- ============================================================================
-- TRIGGER FUNCTIONS
@@ -410,22 +366,6 @@ ALTER TABLE event SET (
);
SELECT add_compression_policy('event', INTERVAL '7 days');
-- Enforcement table (hypertable)
ALTER TABLE enforcement SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'rule_ref',
timescaledb.compress_orderby = 'created DESC'
);
SELECT add_compression_policy('enforcement', INTERVAL '7 days');
-- Execution table (hypertable)
ALTER TABLE execution SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'action_ref',
timescaledb.compress_orderby = 'created DESC'
);
SELECT add_compression_policy('execution', INTERVAL '7 days');
-- ============================================================================
-- RETENTION POLICIES
-- ============================================================================
@@ -433,8 +373,6 @@ SELECT add_compression_policy('execution', INTERVAL '7 days');
SELECT add_retention_policy('execution_history', INTERVAL '90 days');
SELECT add_retention_policy('worker_history', INTERVAL '180 days');
SELECT add_retention_policy('event', INTERVAL '90 days');
SELECT add_retention_policy('enforcement', INTERVAL '90 days');
SELECT add_retention_policy('execution', INTERVAL '90 days');
-- ============================================================================
-- CONTINUOUS AGGREGATES
@@ -449,6 +387,8 @@ DROP MATERIALIZED VIEW IF EXISTS event_volume_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS worker_status_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS enforcement_volume_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS execution_volume_hourly CASCADE;
DROP VIEW IF EXISTS enforcement_volume_hourly CASCADE;
DROP VIEW IF EXISTS execution_volume_hourly CASCADE;
-- ----------------------------------------------------------------------------
-- execution_status_hourly
@@ -553,49 +493,35 @@ SELECT add_continuous_aggregate_policy('worker_status_hourly',
-- instead of a separate enforcement_history table.
-- ----------------------------------------------------------------------------
CREATE MATERIALIZED VIEW enforcement_volume_hourly
WITH (timescaledb.continuous) AS
CREATE VIEW enforcement_volume_hourly AS
SELECT
time_bucket('1 hour', created) AS bucket,
date_trunc('hour', created) AS bucket,
rule_ref,
COUNT(*) AS enforcement_count
FROM enforcement
GROUP BY bucket, rule_ref
WITH NO DATA;
SELECT add_continuous_aggregate_policy('enforcement_volume_hourly',
start_offset => INTERVAL '7 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '30 minutes'
);
;
-- ----------------------------------------------------------------------------
-- execution_volume_hourly
-- Tracks execution creation volume per hour by action_ref and status.
-- This queries the execution hypertable directly (like event_volume_hourly
-- queries the event table). Complements the existing execution_status_hourly
-- and execution_throughput_hourly aggregates which query execution_history.
-- This queries the execution table directly. Complements the existing
-- execution_status_hourly and execution_throughput_hourly aggregates which
-- query execution_history.
--
-- Use case: direct execution volume monitoring without relying on the history
-- trigger (belt-and-suspenders, plus captures the initial status at creation).
-- ----------------------------------------------------------------------------
CREATE MATERIALIZED VIEW execution_volume_hourly
WITH (timescaledb.continuous) AS
CREATE VIEW execution_volume_hourly AS
SELECT
time_bucket('1 hour', created) AS bucket,
date_trunc('hour', created) AS bucket,
action_ref,
status AS initial_status,
COUNT(*) AS execution_count
FROM execution
GROUP BY bucket, action_ref, status
WITH NO DATA;
SELECT add_continuous_aggregate_policy('execution_volume_hourly',
start_offset => INTERVAL '7 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '30 minutes'
);
;
-- ============================================================================
-- INITIAL REFRESH NOTE

View File

@@ -26,7 +26,7 @@ ALTER TABLE artifact ADD COLUMN IF NOT EXISTS content_type TEXT;
-- Total size in bytes of the latest version's content (NULL for progress artifacts)
ALTER TABLE artifact ADD COLUMN IF NOT EXISTS size_bytes BIGINT;
-- Execution that produced/owns this artifact (plain BIGINT, no FK — execution is a hypertable)
-- Execution that produced/owns this artifact (plain BIGINT, no FK by design)
ALTER TABLE artifact ADD COLUMN IF NOT EXISTS execution BIGINT;
-- Structured data for progress-type artifacts and small structured payloads.
@@ -52,7 +52,7 @@ COMMENT ON COLUMN artifact.name IS 'Human-readable artifact name';
COMMENT ON COLUMN artifact.description IS 'Optional description of the artifact';
COMMENT ON COLUMN artifact.content_type IS 'MIME content type (e.g. application/json, text/plain)';
COMMENT ON COLUMN artifact.size_bytes IS 'Size of latest version content in bytes';
COMMENT ON COLUMN artifact.execution IS 'Execution that produced this artifact (no FK — execution is a hypertable)';
COMMENT ON COLUMN artifact.execution IS 'Execution that produced this artifact (no FK by design)';
COMMENT ON COLUMN artifact.data IS 'Structured JSONB data for progress artifacts or metadata';
COMMENT ON COLUMN artifact.visibility IS 'Access visibility: public (all users) or private (scope/owner-restricted)';

21
package-lock.json generated
View File

@@ -1,21 +0,0 @@
{
"name": "attune",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"jose": "^6.2.1"
}
},
"node_modules/jose": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
}
}
}

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"jose": "^6.2.1"
}
}

View File

@@ -17,17 +17,20 @@ Environment Variables:
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import psycopg2
import psycopg2.extras
from psycopg2 import sql
import yaml
# Default configuration
DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/attune"
DEFAULT_PACKS_DIR = "./packs"
SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def generate_label(name: str) -> str:
@@ -64,8 +67,13 @@ class PackLoader:
self.conn.autocommit = False
# Set search_path to use the correct schema
if not SCHEMA_RE.match(self.schema):
raise ValueError(f"Invalid schema name: {self.schema}")
cursor = self.conn.cursor()
cursor.execute(f"SET search_path TO {self.schema}, public")
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query -- This uses psycopg2.sql.Identifier for safe identifier composition after schema-name validation.
cursor.execute(
sql.SQL("SET search_path TO {}, public").format(sql.Identifier(self.schema))
)
cursor.close()
self.conn.commit()
@@ -81,6 +89,16 @@ class PackLoader:
with open(file_path, "r") as f:
return yaml.safe_load(f)
def resolve_pack_relative_path(self, base_dir: Path, relative_path: str) -> Path:
"""Resolve a pack-owned relative path and reject traversal outside the pack."""
candidate = (base_dir / relative_path).resolve()
pack_root = self.pack_dir.resolve()
if not candidate.is_relative_to(pack_root):
raise ValueError(
f"Resolved path '{candidate}' escapes pack root '{pack_root}'"
)
return candidate
def upsert_pack(self) -> int:
"""Create or update the pack"""
print("\n→ Loading pack metadata...")
@@ -412,7 +430,7 @@ class PackLoader:
The database ID of the workflow_definition row, or None on failure.
"""
actions_dir = self.pack_dir / "actions"
full_path = actions_dir / workflow_file_path
full_path = self.resolve_pack_relative_path(actions_dir, workflow_file_path)
if not full_path.exists():
print(f" ⚠ Workflow file '{workflow_file_path}' not found at {full_path}")
return None

118
semgrep-findings.md Normal file
View File

@@ -0,0 +1,118 @@
┌──────────────────┐
│ 14 Code Findings │
└──────────────────┘
 crates/cli/src/commands/pack.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
861┆ std::fs::read_to_string(&pack_yaml_path).context("Failed to read pack.yaml")?;
 crates/cli/src/commands/workflow.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
188┆ std::fs::read_to_string(action_path).context("Failed to read action YAML file")?;
⋮┆----------------------------------------
223┆ std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?;
 crates/cli/src/wait.rs
❯❯❱ javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
❰❰ Blocking ❱❱
Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections.
Details: https://sg.run/GWyz
483┆ /// - `http://api.example.com:9000``ws://api.example.com:8081`
⋮┆----------------------------------------
525┆ Some("ws://api.example.com:8081".to_string())
⋮┆----------------------------------------
529┆ Some("ws://10.0.0.5:8081".to_string())
 crates/common/src/pack_environment.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
694┆ Path::new(env_path),
⋮┆----------------------------------------
812┆ return Ok(PathBuf::from(validated).exists());
 crates/common/src/pack_registry/installer.rs
❯❯❱ rust.actix.ssrf.reqwest-taint.reqwest-taint
❰❰ Blocking ❱❱
Untrusted input might be used to build an HTTP request, which can lead to a Server-side request
forgery (SSRF) vulnerability. SSRF allows an attacker to send crafted requests from the server side
to other internal or external systems. SSRF can lead to unauthorized access to sensitive data and,
in some cases, allow the attacker to control applications or systems that trust the vulnerable
service. To prevent this vulnerability, avoid allowing user input to craft the base request.
Instead, treat it as part of the path or query parameter and encode it appropriately. When user
input is necessary to prepare the HTTP request, perform strict input validation. Additionally,
whenever possible, use allowlists to only interact with expected, trusted domains.
Details: https://sg.run/6D5Y
428┆ .get(parsed_url.clone())
 crates/worker/src/artifacts.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
89┆ let mut file = fs::File::create(&stdout_path)
⋮┆----------------------------------------
123┆ let mut file = fs::File::create(&stderr_path)
⋮┆----------------------------------------
171┆ let mut file = fs::File::create(&result_path)
⋮┆----------------------------------------
217┆ let mut file = fs::File::create(&file_path)
 crates/worker/src/service.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
176┆ config
177┆ .worker
178┆ .as_ref()
179┆ .and_then(|w| w.name.clone())
180┆ .map(|name| format!("/tmp/attune/artifacts/{}", name))
181┆ .unwrap_or_else(|| "/tmp/attune/artifacts".to_string()),

18
web/package-lock.json generated
View File

@@ -1763,9 +1763,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2040,9 +2040,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3005,9 +3005,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {