blob: 55bf2ad6af84cfc426aab9490559db57bd609ef2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use crate::{Epoch, Error, GraphId, OperatorId, WorkId, identity};
use maka_agent_facts::event::Invocation;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
#[schemars(rename = "GraphTarget")]
pub enum Target {
Agent {
#[schemars(length(min = 1))]
agent_id: String,
},
Preset {
#[schemars(length(min = 1))]
preset_id: String,
},
Executor {
#[schemars(length(min = 1))]
executor_id: String,
},
Operator {
operator_id: OperatorId,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HistoricalInput {
pub source_graph_id: GraphId,
#[schemars(length(min = 1))]
pub result_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[schemars(rename = "GraphWorkDefinition")]
pub struct Work {
pub work_id: WorkId,
pub target: Target,
pub instruction: String,
pub input_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub selected_result_inputs: Vec<HistoricalInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaces: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Stop {
#[schemars(length(min = 1))]
pub target_id: String,
#[schemars(length(min = 1))]
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[schemars(rename = "GraphFinish")]
pub struct Finish {
pub result_ids: Vec<String>,
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Source {
pub invocation: Invocation,
pub operation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Update {
pub graph_id: GraphId,
pub source: Source,
pub add_work: Vec<Work>,
pub stop: Vec<Stop>,
pub finish: Option<Finish>,
}
impl Update {
pub fn validate(&self) -> Result<(), Error> {
for id in [
&self.source.invocation.session_id,
&self.source.invocation.turn_id,
&self.source.invocation.run_id,
&self.source.invocation.invocation_id,
&self.source.operation_id,
] {
identity(id)?;
}
if (self.add_work.is_empty() && self.stop.is_empty() && self.finish.is_none())
|| (self.finish.is_some() && !self.add_work.is_empty())
{
return Err(Error::Invalid("invalid schedule update cardinality".into()));
}
let mut work_ids = BTreeSet::new();
for work in &self.add_work {
if !work_ids.insert(&work.work_id) {
return Err(Error::Invalid("duplicate work".into()));
}
text(&work.instruction)?;
match &work.target {
Target::Agent { agent_id } => identity(agent_id)?,
Target::Preset { preset_id } => identity(preset_id)?,
Target::Executor { executor_id } => identity(executor_id)?,
Target::Operator { .. } => {}
}
let mut inputs = BTreeSet::new();
for id in work.input_ids.iter().chain(
work.selected_result_inputs
.iter()
.map(|input| &input.result_id),
) {
identity(id)?;
if !inputs.insert(id) {
return Err(Error::Invalid("duplicate work input".into()));
}
}
for historical in &work.selected_result_inputs {
if historical.source_graph_id == self.graph_id {
return Err(Error::Invalid(
"historical input references current graph".into(),
));
}
}
if let Some(id) = &work.replaces {
identity(id)?;
}
}
let mut stops = BTreeSet::new();
for stop in &self.stop {
identity(&stop.target_id)?;
text(&stop.reason)?;
if !stops.insert(&stop.target_id) {
return Err(Error::Invalid("duplicate stop target".into()));
}
}
if let Some(finish) = &self.finish {
text(&finish.reason)?;
if finish.result_ids.is_empty() {
return Err(Error::Invalid("finish requires results".into()));
}
let mut results = BTreeSet::new();
for id in &finish.result_ids {
identity(id)?;
if !results.insert(id) {
return Err(Error::Invalid("duplicate finish result".into()));
}
}
}
Ok(())
}
pub fn fingerprint(&self) -> Result<String, Error> {
self.validate()?;
let bytes = serde_json::to_vec(self).map_err(|error| Error::Invalid(error.to_string()))?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
}
pub fn identity(&self) -> String {
let bytes = serde_json::to_vec(&self.source).expect("typed source serializes");
format!("graph_update_{}", hex::encode(Sha256::digest(bytes)))
}
}
fn text(value: &str) -> Result<(), Error> {
if value.trim().is_empty() || value.contains('\0') {
return Err(Error::Invalid("invalid instruction or reason".into()));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CommittedUpdate {
pub update: Update,
pub revision: u64,
pub committed_at: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkStatus {
Requested,
Stopped,
Superseded,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScheduledWork {
pub work: Work,
pub status: WorkStatus,
pub revision: u64,
pub committed_at: u64,
}
/// Derived state only. Committed updates remain the business authority.
#[derive(Clone, Debug)]
pub struct Schedule {
pub epoch: Epoch,
pub revision: u64,
pub work: BTreeMap<WorkId, ScheduledWork>,
pub stopped: BTreeMap<String, (Stop, u64, u64)>,
pub finish: Option<(Finish, u64, u64)>,
}
impl Schedule {
pub fn new(epoch: Epoch) -> Result<Self, Error> {
epoch.validate()?;
Ok(Self {
epoch,
revision: 0,
work: BTreeMap::new(),
stopped: BTreeMap::new(),
finish: None,
})
}
pub fn apply(&mut self, committed: &CommittedUpdate) -> Result<(), Error> {
committed.update.validate()?;
if committed.update.graph_id != self.epoch.graph_id
|| committed.update.source.invocation.session_id != self.epoch.root_session_id
|| committed.committed_at > (1 << 53) - 1
{
return Err(Error::Invalid(
"schedule belongs to a different graph root".into(),
));
}
if committed.revision != self.revision.checked_add(1).ok_or(Error::Conflict)? {
return Err(Error::Conflict);
}
if self.finish.is_some() {
return Err(Error::Closed);
}
if committed
.update
.add_work
.iter()
.any(|work| self.work.contains_key(&work.work_id))
{
return Err(Error::Conflict);
}
// Validation completes before projection changes. Runtime record
// existence/readiness is resolved separately; missing inputs wait.
for work in &committed.update.add_work {
if let Some(replaces) = &work.replaces {
if let Some(existing) = self
.work
.values_mut()
.find(|item| item.work.work_id.as_str() == replaces)
{
existing.status = WorkStatus::Superseded;
}
self.stopped.insert(
replaces.clone(),
(
Stop {
target_id: replaces.clone(),
reason: format!("Superseded by {}", work.work_id),
},
committed.revision,
committed.committed_at,
),
);
}
self.work.insert(
work.work_id.clone(),
ScheduledWork {
work: work.clone(),
status: WorkStatus::Requested,
revision: committed.revision,
committed_at: committed.committed_at,
},
);
}
for stop in &committed.update.stop {
self.stopped.insert(
stop.target_id.clone(),
(stop.clone(), committed.revision, committed.committed_at),
);
for work in self.work.values_mut() {
if work.work.work_id.as_str() == stop.target_id
|| matches!(&work.work.target, Target::Operator { operator_id } if operator_id.as_str() == stop.target_id)
{
work.status = WorkStatus::Stopped;
}
}
}
if let Some(finish) = &committed.update.finish {
self.finish = Some((finish.clone(), committed.revision, committed.committed_at));
}
self.revision = committed.revision;
Ok(())
}
}