Skip to main content

wist_api/work/
v1.rs

1//! `agent/work:*` seam 报文 —— **v1** 基线。
2//!
3//! 冻结基线:只做**加性**兼容不动它;非加性变更就新开 `v2`。
4
5use serde::{Deserialize, Serialize};
6
7use super::{OneShotWork, StandingWork};
8
9/// 本版本的线上版本号(与路由 `/api/v1/…` 一致)。
10pub const API_VERSION: &str = wist_contracts::API_VERSION_V1;
11
12/// agentd → 网关:拉取工作授权快照的 envelope kind。
13pub const POLL_WORK_KIND: &str = "poll_work";
14/// agentd → 网关:确认收到工作的 envelope kind。
15pub const ACK_WORK_KIND: &str = "ack_work";
16/// agentd → 网关:上报一次性工作执行结果的 envelope kind。
17pub const REPORT_WORK_RESULT_KIND: &str = "report_work_result";
18
19/// 工作授权快照:常驻工作的当前生效版本 + 未了结的一次性工作。
20///
21/// 幂等、可重复拉取;`sequence` 只用来让 agentd 判断「这份跟我手上的有没有变」,
22/// **不承担「指令重放」的语义**(那是控制指令流的事)。
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct WorkGrant {
26    pub agent_id: String,
27    /// 每个面一条。
28    #[serde(default)]
29    pub standing: Vec<StandingWork>,
30    #[serde(default)]
31    pub one_shot: Vec<OneShotWork>,
32    /// 授权序号(单调递增,每次授权/撤回/暂停/继续都 +1)。
33    pub sequence: i64,
34    pub granted_at: String,
35}
36
37/// agentd → 网关:拉取工作授权快照。
38///
39/// 带 `last_seen_sequence`(与本机手上那份的序号),网关可以据此在没变化时短路;
40/// 带 `wait_ms` 是为了允许将来的长轮询(现在是立即返回,字段先留着,免得改协议)。
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct PollWork {
44    pub api_version: String,
45    pub kind: String,
46    pub agent_id: String,
47    pub instance_id: String,
48    pub last_seen_sequence: i64,
49    pub wait_ms: i64,
50    pub requested_at: String,
51}
52
53/// agentd → 网关:确认收到某份工作。
54///
55/// 常驻工作在 `plan_version` 变化后**也必须**确认:网关据此判断「期望的版本真到了吗」,
56/// 一直没确认的就是漂移。
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct AckWork {
60    pub api_version: String,
61    pub kind: String,
62    pub agent_id: String,
63    pub instance_id: String,
64    pub work_id: String,
65    pub plan_version: i64,
66    pub acknowledged_at: String,
67}
68
69/// 网关对 [`AckWork`] 的回应。
70///
71/// 是**工作域的结构**而不是协议消息(与 [`WorkGrant`] 同类):它描述的是
72/// 「工作已被确认」这个领域事实,也要能被用例当成 outcome 引用。
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct WorkAccepted {
76    pub work_id: String,
77    /// accepted | stale | unknown。
78    pub status: String,
79    pub accepted_at: String,
80}
81
82/// agentd → 网关:上报一次性工作的**执行结果**(进度与终态)。
83///
84/// 与 [`AckWork`] 的分工:确认回答「我收到了」,本消息回答「我做得怎么样了」。
85/// 两者分开是因为它们的**失败代价不同**:确认丢了只是页面晚一拍,结果丢了则意味着
86/// 「一件改变机器状态的活做完了,而控制面永远不知道它成没成」。
87///
88/// `status` 取值见 `wist_contracts::work::AGENT_REPORTABLE_WORK_STATUSES`。
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ReportWorkResult {
92    pub api_version: String,
93    pub kind: String,
94    pub agent_id: String,
95    pub instance_id: String,
96    pub work_id: String,
97    pub status: String,
98    /// 人看的说明:失败原因**原样带上**。
99    #[serde(default)]
100    pub detail: String,
101    pub reported_at: String,
102}
103
104/// 网关对 [`ReportWorkResult`] 的回应。
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct WorkResultAccepted {
108    pub work_id: String,
109    /// accepted | stale | unknown。
110    ///
111    /// `stale` = 这件活已经到终态了(被撤回、超期,或已经报过终态),后到的结果**不覆盖**它。
112    pub status: String,
113    pub accepted_at: String,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn standing() -> StandingWork {
121        StandingWork {
122            work_id: "work-a".to_string(),
123            agent_id: "agent-1".to_string(),
124            family: "LoginSession".to_string(),
125            spec: "unit-a,unit-b".to_string(),
126            catalog_version: 1,
127            proposal_id: None,
128            plan_version: 2,
129            effective_from: "2026-09-23T00:00:00Z".to_string(),
130            status: "active".to_string(),
131            updated_by: "admin".to_string(),
132            updated_at: "2026-09-23T00:00:00Z".to_string(),
133        }
134    }
135
136    fn one_shot() -> OneShotWork {
137        OneShotWork {
138            work_id: "work-1".to_string(),
139            agent_id: "agent-1".to_string(),
140            action: "upgrade".to_string(),
141            spec: "0.1.4".to_string(),
142            scheduled_at: "2026-09-23T00:00:00Z".to_string(),
143            deadline_at: "2026-09-24T00:00:00Z".to_string(),
144            timeout_seconds: 600,
145            interruptible: true,
146            status: "running".to_string(),
147            paused_at: None,
148            paused_total_seconds: 0,
149            current_step: None,
150            completed_steps: vec![],
151            attempt: 0,
152            issued_by: "admin".to_string(),
153            issued_at: "2026-09-23T00:00:00Z".to_string(),
154        }
155    }
156
157    #[test]
158    fn grant_round_trips_with_serde() {
159        let grant = WorkGrant {
160            agent_id: "agent-1".to_string(),
161            standing: vec![standing()],
162            one_shot: vec![one_shot()],
163            sequence: 7,
164            granted_at: "2026-09-23T00:00:00Z".to_string(),
165        };
166        let json = serde_json::to_string(&grant).expect("serialize");
167        let decoded: WorkGrant = serde_json::from_str(&json).expect("deserialize");
168        assert_eq!(decoded, grant);
169    }
170
171    #[test]
172    fn grant_omits_absent_optional_fields_and_still_decodes() {
173        let json = r#"{"agent_id":"a","standing":[],"one_shot":[],"sequence":0,
174                      "granted_at":"t"}"#;
175        let grant: WorkGrant = serde_json::from_str(json).expect("deserialize");
176        assert!(grant.standing.is_empty());
177        assert!(grant.one_shot.is_empty());
178    }
179
180    #[test]
181    fn grant_rejects_unknown_fields() {
182        // 两侧各自演进时,多出来的字段必须是响亮的错误。
183        let json = r#"{"agent_id":"a","standing":[],"one_shot":[],"sequence":0,
184                      "granted_at":"t","extra":1}"#;
185        assert!(serde_json::from_str::<WorkGrant>(json).is_err());
186    }
187}