Skip to main content

rightkit_agent_engine_protocol/
schedule.rs

1//! Canonical schedule-run types — the single definition in the workspace.
2//!
3//! Hoisted from the byte-near-identical duplicates that lived in
4//! `host app/src/scheduler.rs` (write side) and `host app/src/api_schedules.rs`
5//! (read side). The wire JSON is unchanged: all serde attributes moved with the
6//! struct. `ScheduleRunEvent` here is the SUPERSET — it includes the
7//! `schema_version`/`started_at`/`finished_at` evidence fields hermes plan #2
8//! added to the write side, so the read side now exposes them too.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13pub fn default_job_mode() -> String {
14    "agent".to_string()
15}
16
17pub fn default_schema_version() -> u32 {
18    1
19}
20
21#[derive(Clone, Debug, Default, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct ScheduleRunState {
24    #[serde(default)]
25    pub last_claimed_minute: HashMap<String, i64>,
26    #[serde(default)]
27    pub history: Vec<ScheduleRunEvent>,
28}
29
30#[derive(Clone, Debug, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct ScheduleRunEvent {
33    #[serde(default = "default_schema_version")]
34    pub schema_version: u32,
35    pub schedule_id: String,
36    pub epoch_minute: i64,
37    pub status: String,
38    pub session_id: String,
39    pub message: String,
40    #[serde(default, skip_serializing_if = "String::is_empty")]
41    pub started_at: String,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub finished_at: Option<String>,
44    #[serde(default = "default_job_mode")]
45    pub mode: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub toolset: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub execution_backend: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub resolved_execution_backend: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub execution_workspace_id: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub execution_workspace_path: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub resource_limits: Option<ScheduleRunResourceLimits>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub workdir: Option<String>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub skills: Vec<String>,
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub delivery_kinds: Vec<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub source_recipe_id: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub exit_code: Option<i32>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub artifact_path: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub delivery_status: Option<String>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub delivery_errors: Vec<String>,
74}
75
76#[derive(Clone, Debug, Default, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ScheduleRunResourceLimits {
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub cpu: Option<u32>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub memory_mb: Option<u32>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub disk_mb: Option<u32>,
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    /// Full legacy record as the pre-hoist scheduler.rs write path emits it
92    /// (camelCase, includes the hermes evidence fields). Must round-trip.
93    const LEGACY_FULL: &str = r#"{
94        "schemaVersion": 1,
95        "scheduleId": "sched-abc123",
96        "epochMinute": 28521600,
97        "status": "completed",
98        "sessionId": "sess-xyz789",
99        "message": "run finished ok",
100        "startedAt": "2026-06-21T10:00:00Z",
101        "finishedAt": "2026-06-21T10:02:00Z",
102        "mode": "agent",
103        "toolset": "default",
104        "executionBackend": "local",
105        "resolvedExecutionBackend": "local",
106        "executionWorkspaceId": "ws-1",
107        "executionWorkspacePath": "/home/user/ws",
108        "resourceLimits": { "cpu": 2, "memoryMb": 4096, "diskMb": 8192 },
109        "workdir": "/home/user/ws/project",
110        "skills": ["read_file", "write_file"],
111        "deliveryKinds": ["webhook"],
112        "sourceRecipeId": "recipe-001",
113        "exitCode": 0,
114        "artifactPath": "/tmp/out.tar.gz",
115        "deliveryStatus": "delivered",
116        "deliveryErrors": []
117    }"#;
118
119    /// Oldest-format record — written before hermes added schema_version /
120    /// started_at / finished_at. Those three must fall back to their defaults.
121    const PRE_HERMES_MINIMAL: &str = r#"{
122        "scheduleId": "sched-min",
123        "epochMinute": 1,
124        "status": "running",
125        "sessionId": "sess-min",
126        "message": ""
127    }"#;
128
129    #[test]
130    fn schedule_run_event_legacy_full_round_trips() {
131        let ev: ScheduleRunEvent =
132            serde_json::from_str(LEGACY_FULL).expect("full legacy record must deserialize");
133        assert_eq!(ev.schema_version, 1);
134        assert_eq!(ev.schedule_id, "sched-abc123");
135        assert_eq!(ev.epoch_minute, 28521600);
136        assert_eq!(ev.status, "completed");
137        assert_eq!(ev.session_id, "sess-xyz789");
138        assert_eq!(ev.started_at, "2026-06-21T10:00:00Z");
139        assert_eq!(ev.finished_at.as_deref(), Some("2026-06-21T10:02:00Z"));
140        assert_eq!(ev.mode, "agent");
141        let limits = ev.resource_limits.as_ref().expect("resourceLimits present");
142        assert_eq!(limits.cpu, Some(2));
143        assert_eq!(limits.memory_mb, Some(4096));
144        assert_eq!(ev.skills, vec!["read_file", "write_file"]);
145    }
146
147    #[test]
148    fn schedule_run_event_pre_hermes_record_uses_defaults() {
149        let ev: ScheduleRunEvent =
150            serde_json::from_str(PRE_HERMES_MINIMAL).expect("pre-hermes record must deserialize");
151        // The three evidence fields hermes added must default cleanly on old JSON.
152        assert_eq!(ev.schema_version, 1);
153        assert_eq!(ev.started_at, "");
154        assert_eq!(ev.finished_at, None);
155        // Pre-existing optional defaults still hold.
156        assert_eq!(ev.mode, "agent");
157        assert!(ev.toolset.is_none());
158        assert!(ev.resource_limits.is_none());
159        assert!(ev.skills.is_empty());
160    }
161}