1use std::collections::BTreeMap;
3
4use serde::{Deserialize, Serialize};
5
6use crate::schema::{IsoDateTime, NonEmptyString, RunxSchema};
7use crate::{JsonObject, JsonValue};
8
9mod hash;
10
11use hash::{sha256_prefixed, stable_hash_json};
12
13pub const ACT_ASSIGNMENT_SCHEMA: &str = "runx.act_assignment.v1";
14pub const SHA256_ALGORITHM: &str = "sha256";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
17pub enum ActAssignmentSchema {
18 #[serde(rename = "runx.act_assignment.v1")]
19 V1,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum ActAssignmentHostKind {
25 Cli,
26 Api,
27 GithubIssueComment,
28 System,
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
36#[serde(deny_unknown_fields)]
37pub struct ActAssignmentActor {
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub actor_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub display_name: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub role: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub provider_identity: Option<String>,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
49#[serde(deny_unknown_fields)]
50pub struct ActAssignmentHost {
51 pub kind: ActAssignmentHostKind,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub trigger_ref: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub scope_set: Option<Vec<String>>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub actor: Option<ActAssignmentActor>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
61#[serde(deny_unknown_fields)]
62pub struct ActAssignmentIdempotency {
63 pub algorithm: String,
64 pub intent_key: NonEmptyString,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub trigger_key: Option<NonEmptyString>,
67 pub content_hash: NonEmptyString,
68}
69
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
71#[serde(deny_unknown_fields)]
72#[runx_schema(id = "runx.act_assignment.v1")]
73pub struct ActAssignment {
74 pub schema: ActAssignmentSchema,
75 pub skill_ref: NonEmptyString,
76 pub runner: NonEmptyString,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub source_ref: Option<NonEmptyString>,
79 pub requested_at: IsoDateTime,
80 pub host: ActAssignmentHost,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub input_overrides: Option<JsonObject>,
83 pub idempotency: ActAssignmentIdempotency,
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct BuildActAssignment {
89 pub skill_ref: String,
90 pub runner: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub source_ref: Option<String>,
93 pub requested_at: String,
94 pub host: ActAssignmentHost,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub input_overrides: Option<JsonObject>,
97}
98
99impl BuildActAssignment {
100 #[must_use]
101 pub fn build(self) -> ActAssignment {
102 let input_overrides = non_empty_object(self.input_overrides);
103 let source_ref = non_empty_string(self.source_ref);
104 let host = normalize_host(self.host);
105 let trigger_key = derive_trigger_key(host.kind.clone(), host.trigger_ref.clone());
106 let content_hash = derive_content_hash(input_overrides.clone());
107
108 ActAssignment {
109 schema: ActAssignmentSchema::V1,
110 skill_ref: self.skill_ref.clone().into(),
111 runner: self.runner.clone().into(),
112 source_ref: source_ref.clone().map(Into::into),
113 requested_at: self.requested_at.into(),
114 host,
115 input_overrides: input_overrides.clone(),
116 idempotency: ActAssignmentIdempotency {
117 algorithm: SHA256_ALGORITHM.to_owned(),
118 intent_key: derive_intent_key(IntentKeyInput {
119 skill_ref: self.skill_ref,
120 runner: self.runner,
121 source_ref,
122 input_overrides,
123 })
124 .into(),
125 trigger_key: trigger_key.map(Into::into),
126 content_hash: content_hash.into(),
127 },
128 }
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct IntentKeyInput {
135 pub skill_ref: String,
136 pub runner: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub source_ref: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub input_overrides: Option<JsonObject>,
141}
142
143#[must_use]
150pub fn derive_intent_key(input: IntentKeyInput) -> String {
151 sha256_prefixed(&stable_hash_json(&intent_hash_payload(input)))
152}
153
154#[must_use]
158pub fn derive_trigger_key(
159 host_kind: ActAssignmentHostKind,
160 trigger_ref: Option<String>,
161) -> Option<String> {
162 let trigger_ref = non_empty_string(trigger_ref)?;
163 let mut payload = BTreeMap::new();
164 payload.insert(
165 "host_kind".to_owned(),
166 JsonValue::String(host_kind_to_string(&host_kind)),
167 );
168 payload.insert("trigger_ref".to_owned(), JsonValue::String(trigger_ref));
169 Some(sha256_prefixed(&stable_hash_json(&JsonValue::Object(
170 payload,
171 ))))
172}
173
174#[must_use]
178pub fn derive_content_hash(input_overrides: Option<JsonObject>) -> String {
179 sha256_prefixed(&stable_hash_json(&JsonValue::Object(
180 non_empty_object(input_overrides).unwrap_or_default(),
181 )))
182}
183
184fn intent_hash_payload(input: IntentKeyInput) -> JsonValue {
185 let mut payload = BTreeMap::new();
186 payload.insert("skill_ref".to_owned(), JsonValue::String(input.skill_ref));
187 payload.insert("runner".to_owned(), JsonValue::String(input.runner));
188 if let Some(source_ref) = non_empty_string(input.source_ref) {
189 payload.insert("source_ref".to_owned(), JsonValue::String(source_ref));
190 }
191 if let Some(input_overrides) = non_empty_object(input.input_overrides) {
192 payload.insert(
193 "input_overrides".to_owned(),
194 JsonValue::Object(input_overrides),
195 );
196 }
197 JsonValue::Object(payload)
198}
199
200fn non_empty_string(value: Option<String>) -> Option<String> {
201 value.filter(|value| !value.is_empty())
202}
203
204fn non_empty_object(value: Option<JsonObject>) -> Option<JsonObject> {
205 value.filter(|value| !value.is_empty())
209}
210
211fn normalize_host(host: ActAssignmentHost) -> ActAssignmentHost {
212 ActAssignmentHost {
213 kind: host.kind,
214 trigger_ref: non_empty_string(host.trigger_ref),
215 scope_set: normalize_scope_set(host.scope_set),
216 actor: normalize_actor(host.actor),
217 }
218}
219
220fn normalize_scope_set(value: Option<Vec<String>>) -> Option<Vec<String>> {
221 let scope_set: Vec<String> = value
222 .unwrap_or_default()
223 .into_iter()
224 .filter(|scope| !scope.is_empty())
225 .collect();
226 (!scope_set.is_empty()).then_some(scope_set)
227}
228
229fn normalize_actor(actor: Option<ActAssignmentActor>) -> Option<ActAssignmentActor> {
230 let actor = actor?;
231 let normalized = ActAssignmentActor {
232 actor_id: non_empty_string(actor.actor_id),
233 display_name: non_empty_string(actor.display_name),
234 role: non_empty_string(actor.role),
235 provider_identity: non_empty_string(actor.provider_identity),
236 };
237 [
238 &normalized.actor_id,
239 &normalized.display_name,
240 &normalized.role,
241 &normalized.provider_identity,
242 ]
243 .iter()
244 .any(|value| value.is_some())
245 .then_some(normalized)
246}
247
248fn host_kind_to_string(kind: &ActAssignmentHostKind) -> String {
249 match kind {
250 ActAssignmentHostKind::Cli => "cli",
251 ActAssignmentHostKind::Api => "api",
252 ActAssignmentHostKind::GithubIssueComment => "github_issue_comment",
253 ActAssignmentHostKind::System => "system",
254 }
255 .to_owned()
256}