1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
use std::time::Duration;
use serde_json::Value;
use super::models::*;
use crate::error::{Error, Result};
use crate::solver_status::SolverStatusResponse;
use crate::transport::Transport;
const ROUTE: &str = "/api/v1/shift";
/// Client for the shift assignment optimization API.
#[derive(Clone)]
pub struct ShiftClient {
transport: Transport,
}
impl ShiftClient {
pub(crate) fn new(transport: Transport) -> Self {
Self { transport }
}
/// Starts a new solve and returns the job acknowledgement.
///
/// `request` is the shift scheduling problem definition (shifts, employees,
/// contracts, and optional weights/options/fairness). Returns a
/// [`ShiftStartResponse`] carrying the public `job_id` used to poll status and
/// fetch the result, or an [`Error`](crate::Error) for a non-success status.
pub async fn start(&self, request: &ShiftRequest) -> Result<ShiftStartResponse> {
self.transport.post_json(ROUTE, request).await
}
/// Gets the point-in-time status of a solve job by job id.
///
/// `job_id` is the identifier returned by [`start`](Self::start). Returns a
/// [`SolverStatusResponse`] describing the current solver state and best score,
/// or an [`Error`](crate::Error) for a non-success status.
pub async fn get_status(&self, job_id: &str) -> Result<SolverStatusResponse> {
self.transport
.get_json(&format!("{ROUTE}/{job_id}/status"))
.await
}
/// Gets the completed solve result by job id.
///
/// `job_id` is the identifier returned by [`start`](Self::start). Returns a
/// [`ShiftResultResponse`] with the assigned and unassigned shifts (the
/// `job_id` field is stamped client-side), or an [`Error`](crate::Error) for a
/// non-success status.
pub async fn get_result(&self, job_id: &str) -> Result<ShiftResultResponse> {
let mut result: ShiftResultResponse = self
.transport
.get_json(&format!("{ROUTE}/{job_id}"))
.await?;
result.job_id = Some(job_id.to_string());
Ok(result)
}
/// Gets analysis for a completed job by job id.
///
/// `job_id` is the identifier returned by [`start`](Self::start). Returns the
/// raw analysis JSON as a [`serde_json::Value`], or an [`Error`](crate::Error)
/// for a non-success status.
pub async fn analyze(&self, job_id: &str) -> Result<Value> {
self.transport
.get_json(&format!("{ROUTE}/{job_id}/analyze"))
.await
}
/// Stops an in-progress solve and returns the best solution found so far
/// (`DELETE /api/v1/shift/{job_id}`).
///
/// Use this to terminate a long-running solve early instead of waiting for it
/// to hit its own time limit; the response is the same shape as
/// [`get_result`](Self::get_result). `job_id` is the id returned by
/// [`start`](Self::start).
pub async fn stop(&self, job_id: &str) -> Result<ShiftResultResponse> {
let mut result: ShiftResultResponse = self
.transport
.delete_json(&format!("{ROUTE}/{job_id}"))
.await?;
result.job_id = Some(job_id.to_string());
Ok(result)
}
/// Starts a job and polls until it completes.
///
/// Convenience wrapper over [`start`](Self::start) and
/// [`wait_for_completion`](Self::wait_for_completion). `poll_interval_ms` is the
/// delay between status polls (falls back to 5000 when `0`) and `max_attempts`
/// caps the number of polls (falls back to 10 when `0`). Returns the final
/// [`ShiftResultResponse`], or an [`Error`](crate::Error) —
/// [`Error::SolverTimeout`](crate::Error::SolverTimeout) if the solver is still
/// running once the poll budget is exhausted.
pub async fn start_and_wait_for_completion(
&self,
request: &ShiftRequest,
poll_interval_ms: u64,
max_attempts: u32,
) -> Result<ShiftResultResponse> {
let start = self.start(request).await?;
self.wait_for_completion(&start.job_id, poll_interval_ms, max_attempts)
.await
}
/// Polls for job status until the solver finishes or `max_attempts` is reached.
///
/// `job_id` is the identifier returned by [`start`](Self::start);
/// `poll_interval_ms` is the delay between polls (falls back to 5000 when `0`)
/// and `max_attempts` caps the number of polls (falls back to 10 when `0`).
/// Returns the [`ShiftResultResponse`] once solving completes. Returns
/// [`Error::MissingJobId`](crate::Error::MissingJobId) for an empty `job_id` and
/// [`Error::SolverTimeout`](crate::Error::SolverTimeout) if the solver is still
/// running once the poll budget is exhausted.
pub async fn wait_for_completion(
&self,
job_id: &str,
poll_interval_ms: u64,
max_attempts: u32,
) -> Result<ShiftResultResponse> {
if job_id.is_empty() {
return Err(Error::MissingJobId);
}
let poll_interval_ms = if poll_interval_ms == 0 {
5000
} else {
poll_interval_ms
};
let max_attempts = if max_attempts == 0 { 10 } else { max_attempts };
let mut attempts = 0u32;
loop {
tokio::time::sleep(Duration::from_millis(poll_interval_ms)).await;
let status = self.get_status(job_id).await?;
attempts += 1;
let still_solving = status.is_solving();
if !still_solving || attempts >= max_attempts {
if still_solving {
return Err(Error::SolverTimeout);
}
return self.get_result(job_id).await;
}
}
}
}