Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
615 changes: 601 additions & 14 deletions Cargo.lock

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
# `package.version` out of this file as the single source of truth for the
# build, and a virtual root has no `[package]` to read.
[workspace]
members = ["crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-resource-wasip2", "crates/st2-wire", "crates/demo-resolver-wasm"]
members = [
"components/github-issue",
"components/pty-stats",
"crates/agent-spec",
"crates/demo-resolver-wasm",
"crates/st2-resource-protocol",
"crates/st2-resource-providers",
"crates/st2-resource-wasip2",
"crates/st2-wire",
]
default-members = [".", "crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-wire"]

[package]
Expand Down Expand Up @@ -34,6 +43,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
st2-resource-protocol = { path = "crates/st2-resource-protocol" }
st2-resource-providers = { path = "crates/st2-resource-providers", optional = true }
st2-resource-wasip2 = { path = "crates/st2-resource-wasip2", optional = true, features = ["runtime"] }
st2-wire = { path = "crates/st2-wire" }
tempfile = "3"
toml = "0.9"
Expand All @@ -50,9 +61,18 @@ tungstenite = "0.30"

[dev-dependencies]
libc = "0.2"
parking_lot = "0.12"
toml = "0.9"
wat = "1"

[features]
# Sandbox wasm resource-profile resolvers (`agent-spec/profile_wasm`). Off by default: the
# runner binary carries no wasmtime unless a build opts in.
wasm-resolver = ["agent-spec/wasm-resolver"]
# Typed Component Model resource providers. This is the sole feature that admits Wasmtime for
# observation; default members and the default st2 binary remain Wasmtime-free.
wasip2-provider-runtime = [
"wasm-resolver",
"dep:st2-resource-providers",
"dep:st2-resource-wasip2",
]
14 changes: 14 additions & 0 deletions components/github-issue/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "st2-github-issue-component"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
wit-bindgen = "0.57.1"
162 changes: 162 additions & 0 deletions components/github-issue/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};

wit_bindgen::generate!({
path: "../../wit/github-issue",
world: "github-issue-provider",
with: {
"compoundingtech:st2-github-issue/github-issue@0.1.0": generate,
},
});

use compoundingtech::st2_github_issue::github_issue;
use exports::st2::resource_provider::provider_api;

const SELECTOR_SCHEMA: &str = r#"{
"type": "object",
"properties": {
"owner": { "type": "string" },
"repo": { "type": "string" },
"number": { "type": "integer" },
"etag": { "type": "string" },
"topics": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
},
"required": ["owner", "repo", "number"],
"additionalProperties": false
}"#;

struct Component;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Selector {
owner: String,
repo: String,
number: u64,
#[serde(default)]
etag: Option<String>,
#[serde(default)]
topics: Vec<String>,
}

#[derive(Deserialize)]
struct GitHubIssue {
number: u64,
state: String,
title: String,
updated_at: String,
html_url: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Carrier<'a> {
resource: &'static str,
owner: &'a str,
repo: &'a str,
number: u64,
state: &'a str,
title: &'a str,
updated_at: &'a str,
html_url: &'a str,
}

impl provider_api::Guest for Component {
fn describe() -> Result<provider_api::ProviderDescriptor, provider_api::DescriptorError> {
Ok(provider_api::ProviderDescriptor {
capabilities: vec![provider_api::SchedulingCapability::Demand],
selector_schema_json: SELECTOR_SCHEMA.into(),
default_selector_json: "{}".into(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide a default that satisfies the selector schema

When a caller omits a selector and uses this advertised default, {} is missing the schema-required owner, repo, and number fields, so observe immediately returns Failed("invalid GitHub issue selector"). Make the default valid or remove the claim that this provider supplies a usable default.

Useful? React with 👍 / 👎.

topics: vec!["issue".into()],
snapshot_media_type: "application/json".into(),
snapshot_schema_id: "st2.resource.github-issue.v1".into(),
})
}

fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult {
observe(request).unwrap_or_else(|diagnostic| {
provider_api::ObservationResult::Failed(Some(diagnostic))
})
}
}

fn observe(
request: provider_api::ObserveRequest,
) -> Result<provider_api::ObservationResult, String> {
let selector: Selector = serde_json::from_str(&request.selector_json)
.map_err(|_| "invalid GitHub issue selector".to_owned())?;
if selector.owner.is_empty() || selector.repo.is_empty() || selector.number == 0 {
return Err("GitHub issue selector fields must be non-empty".into());
}
let response = github_issue::get(&github_issue::IssueRequest {
owner: selector.owner.clone(),
repo: selector.repo.clone(),
number: selector.number,
etag: selector.etag,
})
.map_err(map_source_error)?;
let (etag, body) = match response {
github_issue::IssueResponse::NotModified(_) => {
return Ok(provider_api::ObservationResult::Unchanged);
}
github_issue::IssueResponse::Ok(value) => value,
};
let issue: GitHubIssue = serde_json::from_slice(&body)
.map_err(|_| "GitHub response was invalid".to_owned())?;
if issue.number != selector.number {
return Err("GitHub response did not match the requested issue".into());
}
let bytes = serde_json::to_vec(&Carrier {
resource: "github-issue",
owner: &selector.owner,
repo: &selector.repo,
number: issue.number,
state: &issue.state,
title: &issue.title,
updated_at: &issue.updated_at,
html_url: &issue.html_url,
})
.map_err(|_| "GitHub response normalization failed".to_owned())?;
let digest = Sha256::digest(&bytes);
if request.prior_digest.as_deref() == Some(digest.as_slice()) {
return Ok(provider_api::ObservationResult::Unchanged);
}
let _ = (request.uri, request.demand_watermark, selector.topics);
let facts = vec![
provider_api::Fact {
key: "state".into(),
before: provider_api::FactValue::Omitted,
after: provider_api::FactValue::Value(issue.state),
},
provider_api::Fact {
key: "etag".into(),
before: provider_api::FactValue::Omitted,
after: etag.map_or(provider_api::FactValue::Null, provider_api::FactValue::Value),
},
];
Ok(provider_api::ObservationResult::Published(
provider_api::Publication {
schema_id: "st2.resource.github-issue.v1".into(),
media_type: "application/json".into(),
bytes,
topics: vec!["issue".into()],
facts: Some(facts),
},
))
}

fn map_source_error(error: github_issue::IssueError) -> String {
match error {
github_issue::IssueError::Denied => "GitHub issue scope denied",
github_issue::IssueError::Unavailable => "GitHub is unavailable",
github_issue::IssueError::ResourceExhausted => "GitHub response exceeded limits",
github_issue::IssueError::DeadlineExceeded => "GitHub request deadline exceeded",
}
.into()
}

export!(Component);
14 changes: 14 additions & 0 deletions components/pty-stats/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "st2-pty-stats-component"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
wit-bindgen = "0.57.1"
140 changes: 140 additions & 0 deletions components/pty-stats/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use serde::Serialize;
use sha2::{Digest as _, Sha256};

wit_bindgen::generate!({
path: "../../wit/pty-stats",
world: "pty-stats-provider",
with: {
"compoundingtech:st2-pty-stats/pty-stats@0.1.0": generate,
},
});

use compoundingtech::st2_pty_stats::pty_stats;
use exports::st2::resource_provider::provider_api;

const SELECTOR_SCHEMA: &str = r#"{
"type": "object",
"properties": {
"session": { "type": "string" },
"topics": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
},
"additionalProperties": false
}"#;

struct Component;

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Selector {
#[serde(default)]
session: Option<String>,
#[serde(default)]
topics: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Carrier<'a> {
resource: &'static str,
scope: Scope<'a>,
stats: &'a serde_json::Value,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
enum Scope<'a> {
All,
Session(&'a str),
}

impl provider_api::Guest for Component {
fn describe() -> Result<provider_api::ProviderDescriptor, provider_api::DescriptorError> {
Ok(provider_api::ProviderDescriptor {
capabilities: vec![provider_api::SchedulingCapability::Demand],
selector_schema_json: SELECTOR_SCHEMA.into(),
default_selector_json: "{}".into(),
topics: vec!["stats".into()],
snapshot_media_type: "application/json".into(),
snapshot_schema_id: "st2.resource.pty-stats.v1".into(),
})
}

fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult {
observe(request).unwrap_or_else(|diagnostic| {
provider_api::ObservationResult::Failed(Some(diagnostic))
})
}
}

fn observe(
request: provider_api::ObserveRequest,
) -> Result<provider_api::ObservationResult, String> {
let selector: Selector = serde_json::from_str(&request.selector_json)
.map_err(|_| "invalid PTY stats selector".to_owned())?;
if selector.session.as_deref().is_some_and(str::is_empty) {
return Err("PTY session scope must be non-empty".into());
}
let scope = selector.session.as_ref().map_or(pty_stats::Scope::All, |session| {
pty_stats::Scope::Session(session.clone())
});
let outcome = pty_stats::get(&scope).map_err(map_source_error)?;
if outcome.stdout_truncated || outcome.stderr_truncated {
return Err("PTY stats output exceeded limits".into());
}
match outcome.exit {
pty_stats::ExitStatus::Code(0) => {}
pty_stats::ExitStatus::Code(_) | pty_stats::ExitStatus::Signal(_) => {
return Ok(provider_api::ObservationResult::Failed(Some(
"pty stats exited unsuccessfully".into(),
)));
}
}
let stats: serde_json::Value = serde_json::from_slice(&outcome.stdout)
.map_err(|_| "pty stats returned invalid JSON".to_owned())?;
let carrier_scope = selector
.session
.as_deref()
.map_or(Scope::All, Scope::Session);
let scope_fact = selector.session.clone().unwrap_or_else(|| "all".into());
let bytes = serde_json::to_vec(&Carrier {
resource: "pty-stats",
scope: carrier_scope,
stats: &stats,
})
.map_err(|_| "PTY stats normalization failed".to_owned())?;
let digest = Sha256::digest(&bytes);
if request.prior_digest.as_deref() == Some(digest.as_slice()) {
return Ok(provider_api::ObservationResult::Unchanged);
}
let _ = (request.uri, request.demand_watermark, selector.topics);
Ok(provider_api::ObservationResult::Published(
provider_api::Publication {
schema_id: "st2.resource.pty-stats.v1".into(),
media_type: "application/json".into(),
bytes,
topics: vec!["stats".into()],
facts: Some(vec![provider_api::Fact {
key: "scope".into(),
before: provider_api::FactValue::Omitted,
after: provider_api::FactValue::Value(scope_fact),
}]),
},
))
}

fn map_source_error(error: pty_stats::PtyStatsError) -> String {
match error {
pty_stats::PtyStatsError::Denied => "PTY stats scope denied",
pty_stats::PtyStatsError::Unavailable => "PTY stats is unavailable",
pty_stats::PtyStatsError::ResourceExhausted => "PTY stats output exceeded limits",
pty_stats::PtyStatsError::DeadlineExceeded => "PTY stats deadline exceeded",
pty_stats::PtyStatsError::Cancelled => "PTY stats was cancelled",
}
.into()
}

export!(Component);
Loading
Loading