wist-contracts 0.3.0

Versioned contract and schema objects shared by the wist edge and center components
Documentation
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
//! **工作授权快照**:网关授权(grant)、agentd 拉取(`WorkGrant`)并确认。
//!
//! ## 为什么是「快照」而不是「指令流」
//!
//! 常驻工作(`StandingWork`)表达的是**期望状态**:某个采集面应当持续执行什么。
//! 期望状态天然是幂等的(同一份重复拉取不产生副作用),断了网、重启了进程,
//! 再拉一次就回到期望 —— 不必让网关记住「上次推到哪条」。
//! 这跟 `PollControlCommands` 的长轮询指令流是两种东西:那条要求**不重放**,这条要求**可重拉**。
//!
//! 一次性工作(`OneShotWork`)是命令式的,但它也搭这份快照回来:
//! 决定「现在还该做吗」的是它的状态(未了结才出现在快照里),不是「推没推过」。
//!
//! ## 为什么在契约 crate
//!
//! 这是**两侧都要解析**的字节:网关写授权、agentd 读并执行。各写一份结构体,
//! 迟早出现「网关发了 `plan_version`、agentd 读的是 `version`」这种只能在真机联调时才发现的分叉。
//! 与 `DiscoveryAspectPolicySet` 同一个理由。
//!
//! ## 与本地保护暂停的区别
//!
//! agentd 自己也会暂停(如 `spool over limit`),那是**本地保护**:自动、临时、本机可见。
//! 这里下发的 `paused` 是**授权层状态**:人工、持久、跨重启。两者在观测里必须能分辨,
//! 不要合并成一个 paused —— 合并之后「谁把采集停了」就再也说不清。

use serde::{Deserialize, Serialize};

/// agentd → 网关:拉取工作授权快照的 envelope kind。
pub const POLL_WORK_KIND: &str = "poll_work";
/// agentd → 网关:确认收到工作的 envelope kind。
pub const ACK_WORK_KIND: &str = "ack_work";
/// agentd → 网关:上报一次性工作执行结果的 envelope kind。
pub const REPORT_WORK_RESULT_KIND: &str = "report_work_result";

/// 常驻工作的状态取值(对应模型 `StandingWork.status`)。
///
/// `superseded` 与 `revoked` 都不出现在 `WorkGrant.standing` 里:前者是「被新版本取代」,
/// 后者是「授权被撤」。两者都要留痕,所以还是要有状态而不是删行。
pub const STANDING_WORK_STATUSES: [&str; 4] = ["active", "paused", "superseded", "revoked"];

/// 一次性工作的状态取值(对应模型 `OneShotWork.status`)。
pub const ONE_SHOT_WORK_STATUSES: [&str; 9] = [
    "dispatched",
    "accepted",
    "running",
    "paused",
    "succeeded",
    "failed",
    "timed_out",
    "canceled",
    "expired",
];

/// Agent **允许上报**的一次性工作状态([`ONE_SHOT_WORK_STATUSES`] 的真子集)。
///
/// 为什么不是全集:`dispatched` 是网关自己写的(派下去那一刻),`paused` / `canceled` / `expired`
/// 归**控制面与期限**管(运维暂停撤回、或过了截止)—— agent 无权把它们写回去。
/// 剩下的问题只有一种:「这件活做完了没有」,答案就这三种。
pub const AGENT_REPORTABLE_WORK_STATUSES: [&str; 3] = ["running", "succeeded", "failed"];

/// 一次性工作的**终态**:到了这几个状态就了结了,不再出现在快照里。
pub const ONE_SHOT_TERMINAL_STATUSES: [&str; 5] =
    ["succeeded", "failed", "timed_out", "canceled", "expired"];

/// 工作类型:常驻(持续到被替换或撤回)与一次性(有期限与终态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Work")]
#[serde(rename_all = "PascalCase")]
pub enum WorkKind {
    /// 常驻工作:按**采集面**授权,一个面一份。
    Standing,
    /// 一次性工作:按**动作**授权。
    OneShot,
}

/// 常驻工作:网关声明该 Agent 应当持续执行的工作。
///
/// 粒度是**采集面**(一个面 = 一份工作),不是 capability:一份 `collect_logs` 会裹住十几个面,
/// 那样「按面暂停 / 限流 / 审计」全都无从下手。面由模板的 `family_scope` 展开而来,
/// **不携带 capability** —— 该面由哪个采集器承接,由 `spec` 里的单元各自决定。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct StandingWork {
    pub work_id: String,
    pub agent_id: String,
    /// 采集面(`CollectionFamily`)。
    pub family: String,
    /// 工作参数:由采集目录的条目组合而成(**不是自由文本**),随 `plan_version` 整体替换。
    pub spec: String,
    /// 本工作按哪一版目录展开:目录换版**不追改**已授权工作(要跟新版得走新提案 + 审定)。
    pub catalog_version: i64,
    /// 生效依据:指向已批准的提案(人工直填 spec 时为空)。
    #[serde(default)]
    pub proposal_id: Option<String>,
    /// 期望版本:网关每次改动 +1;agentd 回报实际版本,与它比对即得漂移。
    pub plan_version: i64,
    pub effective_from: String,
    /// 见 [`STANDING_WORK_STATUSES`]。
    pub status: String,
    pub updated_by: String,
    pub updated_at: String,
}

/// 一次性工作:有计划开始时间与期限,有明确终态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct OneShotWork {
    pub work_id: String,
    pub agent_id: String,
    /// 动作面:upgrade / snapshot / exec / ...(执行载体是 Reporting 域的 ActionPlan)。
    pub action: String,
    pub spec: String,
    /// 计划开始时间:到点前不应执行(与「立即派发」区分开)。
    pub scheduled_at: String,
    /// 绝对截止:业务要求的时间点,**暂停也照走**(不由暂停顺延)。
    pub deadline_at: String,
    /// 执行预算(秒):**只在实际执行时消耗**,暂停期间不计。
    pub timeout_seconds: i64,
    /// 可中断性:只有可中断的动作才允许运行中暂停。
    pub interruptible: bool,
    /// 见 [`ONE_SHOT_WORK_STATUSES`]。
    pub status: String,
    /// 当前暂停的起点(运行中暂停才有;恢复后清空)。
    #[serde(default)]
    pub paused_at: Option<String>,
    /// 累计暂停时长(秒):用于审计与「预算未被暂停消耗」的核对。
    pub paused_total_seconds: i64,
    /// 步级断点:已完成步骤保留、`current_step` 在恢复时重做。
    #[serde(default)]
    pub current_step: Option<String>,
    #[serde(default)]
    pub completed_steps: Vec<String>,
    /// 已尝试次数(含恢复后的重做)。
    pub attempt: i64,
    pub issued_by: String,
    pub issued_at: String,
}

impl OneShotWork {
    /// 是否**未了结**(快照里只带未了结的活)。
    pub fn is_outstanding(&self) -> bool {
        !ONE_SHOT_TERMINAL_STATUSES.contains(&self.status.as_str())
    }

    /// 是否允许在运行期间暂停。
    ///
    /// 两条都要满足:动作声明了 `interruptible`,且此刻确实在做(`accepted`/`running`)。
    /// 不可中断的动作**拒绝**而不是「尽力暂停」—— 挂起半个升级进程比不暂停更危险。
    pub fn can_pause(&self) -> bool {
        self.interruptible && matches!(self.status.as_str(), "accepted" | "running")
    }
}

/// 工作授权快照:常驻工作的当前生效版本 + 未了结的一次性工作。
///
/// 幂等、可重复拉取;`sequence` 只用来让 agentd 判断「这份跟我手上的有没有变」,
/// **不承担「指令重放」的语义**(那是控制指令流的事)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct WorkGrant {
    pub agent_id: String,
    /// 每个面一条。
    #[serde(default)]
    pub standing: Vec<StandingWork>,
    #[serde(default)]
    pub one_shot: Vec<OneShotWork>,
    /// 授权序号(单调递增,每次授权/撤回/暂停/继续都 +1)。
    pub sequence: i64,
    pub granted_at: String,
}

/// 管理面授权或撤回工作的回执:一份工作一次。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct WorkReceipt {
    pub work_id: String,
    pub agent_id: String,
    pub work_kind: WorkKind,
    /// accepted | paused | resumed | revoked | rejected。
    pub status: String,
    pub plan_version: i64,
    pub created_at: String,
}

/// agentd → 网关:拉取工作授权快照。
///
/// 带 `last_seen_sequence`(与本机手上那份的序号),网关可以据此在没变化时短路;
/// 带 `wait_ms` 是为了允许将来的长轮询(现在是立即返回,字段先留着,免得改协议)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
    kind = "message",
    role = "command",
    domain = "Control",
    module = "Control.AgentApp.FacingInterface"
)]
#[serde(deny_unknown_fields)]
pub struct PollWork {
    pub api_version: String,
    pub kind: String,
    pub agent_id: String,
    pub instance_id: String,
    pub last_seen_sequence: i64,
    pub wait_ms: i64,
    pub requested_at: String,
}

/// agentd → 网关:确认收到某份工作。
///
/// 常驻工作在 `plan_version` 变化后**也必须**确认:网关据此判断「期望的版本真到了吗」,
/// 一直没确认的就是漂移。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
    kind = "message",
    role = "command",
    domain = "Control",
    module = "Control.AgentApp.FacingInterface"
)]
#[serde(deny_unknown_fields)]
pub struct AckWork {
    pub api_version: String,
    pub kind: String,
    pub agent_id: String,
    pub instance_id: String,
    pub work_id: String,
    pub plan_version: i64,
    pub acknowledged_at: String,
}

/// 网关对 [`AckWork`] 的回应。
///
/// 是**工作域的结构**而不是协议消息(与 [`WorkGrant`] 同类):它描述的是
/// 「工作已被确认」这个领域事实,也要能被用例当成 outcome 引用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct WorkAccepted {
    pub work_id: String,
    /// accepted | stale | unknown。
    pub status: String,
    pub accepted_at: String,
}

/// agentd → 网关:上报一次性工作的**执行结果**(进度与终态)。
///
/// 与 [`AckWork`] 的分工:确认回答「我收到了」,本消息回答「我做得怎么样了」。
/// 两者分开是因为它们的**失败代价不同**:确认丢了只是页面晚一拍,结果丢了则意味着
/// 「一件改变机器状态的活做完了,而控制面永远不知道它成没成」。
///
/// 为什么必须由 agent 上报而不是网关自己推:网关只知道派了什么;一件升级是否真的换上了、
/// 失败了有没有回滚,只有在机器上的那个执行体知道。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
    kind = "message",
    role = "command",
    domain = "Control",
    module = "Control.AgentApp.FacingInterface"
)]
#[serde(deny_unknown_fields)]
pub struct ReportWorkResult {
    pub api_version: String,
    pub kind: String,
    pub agent_id: String,
    pub instance_id: String,
    pub work_id: String,
    /// 见 [`AGENT_REPORTABLE_WORK_STATUSES`]。
    pub status: String,
    /// 人看的说明:失败原因**原样带上**(如「摘要不符」「新版 60s 没起来,已回滚到 0.1.3」)。
    /// 不写清原因,页面上就只剩一个无法解释的「失败」。
    #[serde(default)]
    pub detail: String,
    pub reported_at: String,
}

/// 网关对 [`ReportWorkResult`] 的回应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct WorkResultAccepted {
    pub work_id: String,
    /// accepted | stale | unknown。
    ///
    /// `stale` = 这件活已经到终态了(被撤回、超期,或已经报过终态),后到的结果**不覆盖**它:
    /// 一件活只能有一个终态,写第二次只会把历史抹掉。
    pub status: String,
    pub accepted_at: String,
}

// ─────────────────────────────────────────────────────────────────────────────
// 工作参数(`StandingWork.spec` 的内容)
// ─────────────────────────────────────────────────────────────────────────────
//
// 模型里 `StandingWork.spec` 是一个 `String`,语义是「工作参数:由采集目录的条目
// 组合而成,**不是自由文本**」。这里的三个类型就是它的**编码**:一串已物化的采集单元。
//
// 为什么必须带上来源与规则标识,而不是只给一串 `unit_id`:agentd 拿到工作要能
// **直接照做**。单元的采集来源(`sources`)与数据面规则标识(`rule_ref`)本来都只
// 存在于网关的采集目录里,只发 id 等于发了一张自己去不了的地址 —— 于是要么再去网关
// 拉一次目录(多一条必须鉴权的路径),要么两边各维护一份目录(必然漂移)。
//
// 为什么不把它们塞成 `StandingWork` 的字段:那会把「工作」与「内容目录」的边界糊掉;
// 模型里这个字段就是**不透明的工作参数**,保持它不透明是两侧能独立演进的前提。

/// 一份常驻工作的工作参数。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpec {
    /// 本工作包含的采集单元(已按该机器的事实裁剪过)。
    #[serde(default)]
    pub units: Vec<WorkSpecUnit>,
}

/// 一个已物化的采集单元:只说「采什么、怎么落地」,不复述策展元信息。
///
/// 刻意不带 `status` / `match` / `catalog_version`:那些是网关策展与裁剪的输入,
/// 工作一旦发出去就已经裁剪完了。带上它们会让 agentd 有「再判断一次」的空间,
/// 而 agentd 不是第二个策展器。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpecUnit {
    pub unit_id: String,
    /// `collect_logs` | `collect_metrics`:该单元由哪类采集器承接。
    pub capability: String,
    /// 数据面 rule/oml 标识(空 = 该单元还没接上规则,理论不会入工作)。
    #[serde(default)]
    pub rule_ref: String,
    /// `none` | `root` | `fda`:要采到这东西得有什么权限 —— 缺权限时应**说清缺什么**,
    /// 而不是安静地采不到。
    #[serde(default)]
    pub requires_privilege: String,
    #[serde(default)]
    pub sources: Vec<WorkSpecSource>,
}

/// 采集来源:`kind` ∈ `FileGlob` | `Exporter` | `UnifiedLogPredicate` | `MetricInterval`。
///
/// 与模型 `CollectionSource` 同形:`target` 的含义由 `kind` 决定
/// (路径通配 / 导出器标识 / 谓词 / 周期)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpecSource {
    pub kind: String,
    pub target: String,
    /// 怎么读这条来源:`none`(一行一条)| `indented`(行首缩进是上一条的续行)。
    ///
    /// 只对可 tail 的 `FileGlob` 有意义,其余 kind 恒为 `none`。**不是**"要不要多行"这个偏好,
    /// 而是这条日志的**固有格式**:说错了要么把多行记录拆散、要么把独立记录粘成一条。
    #[serde(default = "default_multiline")]
    pub multiline: String,
}

fn default_multiline() -> String {
    "none".to_string()
}

impl WorkSpec {
    /// 解析 `StandingWork.spec`。
    ///
    /// 解析失败**不当作空工作**:空工作会让「参数坏了」退化成「没事可做」,
    /// 两种情形的运维动作完全不同。
    pub fn parse(spec: &str) -> Result<Self, serde_json::Error> {
        serde_json::from_str(spec)
    }

    /// 序列化(网关写 `spec` 用)。字段顺序固定 → 同一份工作每次编码都一样,
    /// `plan_version` 之外的字节也稳定,方便对账。
    pub fn encode(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(self)
    }

    /// 周期类来源声明的间隔(秒):取所有 `MetricInterval` 里最密的那一个。
    ///
    /// 取最密而不是取第一个:一份工作里若有两个指标单元,采慢的那个会
    /// 静默压掉采密的要求,而“要得最急”的需求才是上送频率的下界。
    pub fn metric_interval_seconds(&self) -> Option<i64> {
        self.units
            .iter()
            .flat_map(|unit| unit.sources.iter())
            .filter(|source| source.kind == "MetricInterval")
            .filter_map(|source| parse_interval_seconds(&source.target))
            .min()
    }

    /// 是否要采日志(有 `collect_logs` 单元)。
    pub fn collects_logs(&self) -> bool {
        self.units
            .iter()
            .any(|unit| unit.capability == "collect_logs")
    }
}

/// agentd **有可能真的执行**的来源 kind(其余 kind 一律 `unsupported`,不当没看见)。
///
/// 注意:kind 对了**还不够** —— 目标形态也有限制,见 [`is_executable_source`]:
/// `FileGlob` 要是显式绝对路径、`Exporter` 要是**已知导出器 ID**。只按 kind 判会把
/// 「通配目标」「写错的导出器」当成可采,而那在采集端落不了地。
pub const EXECUTABLE_SOURCE_KINDS: &[&str] = &["FileGlob", "MetricInterval", "Exporter"];

/// 目标里有没有通配元字符 —— 有就说明它不是**显式单路径**。
pub fn has_glob_meta(target: &str) -> bool {
    target.contains(['*', '?', '['])
}

/// 这个目标是不是采集端今天**真能打开**的路径:**绝对路径、无通配**。
///
/// 采集端(agentd 第一版)只实现显式单路径输入:**不做 glob 展开、也不展开 `~`**
/// (见 `wist/wist-agentd/docs/design/log-file-input-spec.md` §2 的能力边界)。
/// 所以 `/var/log/wifi.log*`、`~/Library/Logs/Homebrew/*` 这类目标今天都采不到。
pub fn is_explicit_path(target: &str) -> bool {
    target.starts_with('/') && !has_glob_meta(target)
}

/// 定时导出器(`Exporter`)的**已知 ID**:协议层词表,网关 / agentd / web 三侧共用。
///
/// 为什么词表放这里:`is_executable_source` 要判「这条 `Exporter` 目标今天真能不能采」,
/// 而网关的采集就绪度与 agentd 的「接不接得了」必须是**同一个判据**(见 [`is_executable_source`])。
/// 词表只收**已实现**的导出器 —— 收录一个 ID 就等于承诺 agentd 能跑它;未知 ID(旧网关发新 ID、
/// 或写错)一律当不可采,不要「写了就算」。新增能力 = 四侧(本 crate + 网关 + agentd + web)
/// 一起改 + 知识库把对应单元开 `active`。
pub const EXPORTER_IDS: &[&str] = &[
    "journalctl-unit",
    "journalctl-shutdown",
    "last-reboot",
    "nft-ruleset",
    "iptables-save",
    "smartctl",
    "dmesg",
    "auditd-execve",
];

/// 把一个 `Exporter` 目标拆成 `(id, arg)`:`"dmesg:panic"` → `("dmesg", Some("panic"))`。
///
/// 无 `:` 时 `arg` 为 `None`;空 id 或含空白的 id 视为无效(返回 `None`)。`arg` 是**声明式的窄选项**
/// (如 `dmesg` 的过滤键),不是命令行片段 —— 实现侧各自解释,绝不拼进 shell。
pub fn parse_exporter_target(target: &str) -> Option<(&str, Option<&str>)> {
    let (id, arg) = match target.split_once(':') {
        Some((id, arg)) => (id, Some(arg)),
        None => (target, None),
    };
    let id = id.trim();
    if id.is_empty() || id.contains(char::is_whitespace) {
        return None;
    }
    Some((id, arg))
}

/// 这条 `Exporter` 目标的 ID 是不是**已知且已实现**的导出器。
pub fn is_known_exporter(target: &str) -> bool {
    match parse_exporter_target(target) {
        Some((id, _)) => EXPORTER_IDS.contains(&id),
        None => false,
    }
}

/// 一条来源今天**能不能被采**:`kind` 要 agentd 能执行,`target` 还要是显式路径。
///
/// 为什么把两件事放在一个函数里:网关的采集就绪度(「这个面能不能派下去」)与 agentd 的
/// 「这条来源接不接得了」必须是**同一个判据**,否则会出现「网关说可采、agent 拿到后报
/// unsupported」这种没人能发现的矛盾。加一种 kind 支持时**只改这里**。
pub fn is_executable_source(kind: &str, target: &str) -> bool {
    match kind {
        // 本地文件采集:只支持显式绝对路径(无 glob、无 `~`)。
        "FileGlob" => is_explicit_path(target),
        // 指标:`target` 是采集周期(如 `15s`、`60s`),不是路径。
        "MetricInterval" => true,
        // 定时导出器:`target` 要是**已知导出器 ID**(可带 `:arg`)。未知 ID 不可执行。
        "Exporter" => is_known_exporter(target),
        _ => false,
    }
}

impl WorkSpecUnit {
    /// 该单元里可以交给本地文件采集器的来源(`FileGlob`)。
    ///
    /// 其它来源(导出器 / 统一日志谓词)不是本地 tail 一个文件能承接的:
    /// 调用方应当把它们当成**还没支持的单元**如实报出来,而不是当没看见。
    pub fn file_sources(&self) -> Vec<&WorkSpecSource> {
        self.sources
            .iter()
            .filter(|source| source.kind == "FileGlob")
            .collect()
    }

    /// 本单元有没有 agentd 目前接不了的来源(kind 不认识,或目标不是显式路径)。
    pub fn unsupported_sources(&self) -> Vec<&WorkSpecSource> {
        self.sources
            .iter()
            .filter(|source| !is_executable_source(&source.kind, &source.target))
            .collect()
    }

    /// 本单元至少有一条来源是 agentd **今天真能采**的。
    ///
    /// 「能不能采」只看这个:与解析规则(`rule_ref`)无关 —— 采原文不需要规则,
    /// 规则只决定采下来的东西能不能被归类、抽字段。
    pub fn has_executable_source(&self) -> bool {
        self.sources
            .iter()
            .any(|source| is_executable_source(&source.kind, &source.target))
    }
}

/// 解析 `15s` / `60s` / `5m` 这类周期写法(与目录里 `MetricInterval` 的写法一致)。
///
/// 只认秒与分两种后缀:目录里的值是人写的,多一个单位就多一种笔误的可能,
/// 而解析不了时返回 `None` 会让调用方回退到自己的默认值(不静默取 0)。
pub fn parse_interval_seconds(raw: &str) -> Option<i64> {
    let raw = raw.trim();
    if let Some(value) = raw.strip_suffix('s') {
        return value.trim().parse::<i64>().ok().filter(|value| *value > 0);
    }
    if let Some(value) = raw.strip_suffix('m') {
        let minutes = value.trim().parse::<i64>().ok()?;
        // `checked_mul`:`123456789012345678m` 这类输入会让 `minutes * 60` 溢出 i64。
        // 契约是「解析不了返回 None」(调用方回退到自己的默认值),不能让它 panic(debug)
        // 或悄悄回绕成一个错的间隔(release)。
        return minutes.checked_mul(60).filter(|value| *value > 0);
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn standing(work_id: &str, family: &str, plan_version: i64) -> StandingWork {
        StandingWork {
            work_id: work_id.to_string(),
            agent_id: "agent-1".to_string(),
            family: family.to_string(),
            spec: "unit-a,unit-b".to_string(),
            catalog_version: 1,
            proposal_id: None,
            plan_version,
            effective_from: "2026-09-23T00:00:00Z".to_string(),
            status: "active".to_string(),
            updated_by: "admin".to_string(),
            updated_at: "2026-09-23T00:00:00Z".to_string(),
        }
    }

    fn one_shot(status: &str, interruptible: bool) -> OneShotWork {
        OneShotWork {
            work_id: "work-1".to_string(),
            agent_id: "agent-1".to_string(),
            action: "upgrade".to_string(),
            spec: "0.1.4".to_string(),
            scheduled_at: "2026-09-23T00:00:00Z".to_string(),
            deadline_at: "2026-09-24T00:00:00Z".to_string(),
            timeout_seconds: 600,
            interruptible,
            status: status.to_string(),
            paused_at: None,
            paused_total_seconds: 0,
            current_step: None,
            completed_steps: vec![],
            attempt: 0,
            issued_by: "admin".to_string(),
            issued_at: "2026-09-23T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn terminal_one_shot_work_is_not_outstanding() {
        // 未了结的活才进快照:终态留在库里供审计,但不必再发给 agent。
        for status in ONE_SHOT_TERMINAL_STATUSES {
            assert!(!one_shot(status, true).is_outstanding(), "{status}");
        }
        for status in ["dispatched", "accepted", "running", "paused"] {
            assert!(one_shot(status, true).is_outstanding(), "{status}");
        }
    }

    #[test]
    fn only_interruptible_running_work_can_pause() {
        // 不可中断:拒绝,而不是「尽力暂停」。
        assert!(!one_shot("running", false).can_pause());
        // 可中断但没在做(还没到点 / 已经了结):也没什么可暂停的。
        assert!(!one_shot("dispatched", true).can_pause());
        assert!(!one_shot("succeeded", true).can_pause());
        assert!(one_shot("accepted", true).can_pause());
        assert!(one_shot("running", true).can_pause());
    }

    #[test]
    fn work_kind_serializes_as_the_model_names() {
        assert_eq!(
            serde_json::to_string(&WorkKind::Standing).expect("serialize"),
            "\"Standing\""
        );
        assert_eq!(
            serde_json::to_string(&WorkKind::OneShot).expect("serialize"),
            "\"OneShot\""
        );
    }

    #[test]
    fn only_explicit_absolute_paths_are_collectable_file_targets() {
        // 采集端(agentd 第一版)只实现**显式单路径**:不做 glob 展开、不展开 `~`。
        // 这个判据被两侧共用(网关的采集就绪度 + agentd 的接受逻辑),所以在这里钉死。
        assert!(is_explicit_path("/var/log/install.log"));
        assert!(!is_explicit_path("/var/log/wifi.log*"));
        assert!(!is_explicit_path("/Library/Logs/DiagnosticReports/*.ips"));
        assert!(!is_explicit_path("~/Library/Logs/Homebrew/*"));
        assert!(!is_explicit_path("relative/app.log"));
    }

    #[test]
    fn executability_needs_both_a_supported_kind_and_a_supported_target() {
        // 指标:`target` 是周期不是路径,不管显式路径那一条。
        assert!(is_executable_source("MetricInterval", "15s"));
        // 文件:kind 对了,目标还得是显式路径 —— 否则网关会把采不到的面报成“可采”。
        assert!(is_executable_source("FileGlob", "/var/log/app.log"));
        assert!(!is_executable_source("FileGlob", "/var/log/app*"));
        // 导出器:kind 对了,ID 还得是**已知的**(`last,lastb` 是 macOS 侧、尚未实现)。
        assert!(is_executable_source("Exporter", "smartctl"));
        assert!(is_executable_source("Exporter", "dmesg:panic"));
        assert!(!is_executable_source("Exporter", "last,lastb"));
        // 还没实现的 kind:一律不可执行。
        assert!(!is_executable_source("UnifiedLogPredicate", "syspolicyd"));
    }

    #[test]
    fn exporter_targets_split_into_a_known_id_and_an_optional_arg() {
        assert_eq!(parse_exporter_target("smartctl"), Some(("smartctl", None)));
        assert_eq!(
            parse_exporter_target("dmesg:panic"),
            Some(("dmesg", Some("panic")))
        );
        // 空 id / 含空白的 id 不是合法 ID。
        assert_eq!(parse_exporter_target(":panic"), None);
        assert_eq!(parse_exporter_target(" dm esg"), None);
        // 判定只看冒号前的 ID,`arg` 不参与。
        assert!(is_known_exporter("dmesg:nvidia-xid"));
        assert!(!is_known_exporter("nope"));
    }

    #[test]
    fn grant_round_trips_with_serde() {
        let grant = WorkGrant {
            agent_id: "agent-1".to_string(),
            standing: vec![standing("work-a", "LoginSession", 2)],
            one_shot: vec![one_shot("running", true)],
            sequence: 7,
            granted_at: "2026-09-23T00:00:00Z".to_string(),
        };
        let json = serde_json::to_string(&grant).expect("serialize");
        let decoded: WorkGrant = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(decoded, grant);
    }

    #[test]
    fn grant_omits_absent_optional_fields_and_still_decodes() {
        // `proposal_id` / `paused_at` / `current_step` 缺省时必须能解析:
        // 手写 JSON(页面、联调)不该被迫填一堆 null。
        let json = r#"{"agent_id":"a","standing":[],"one_shot":[],"sequence":0,
                      "granted_at":"t"}"#;
        let grant: WorkGrant = serde_json::from_str(json).expect("deserialize");
        assert!(grant.standing.is_empty());
        assert!(grant.one_shot.is_empty());
    }

    #[test]
    fn rejects_unknown_fields() {
        // 两侧各自演进时,多出来的字段必须是响亮的错误:静默忽略会让
        // 「网关发了新字段、agentd 装作没看见」变成长期无声的语义分叉。
        let json = r#"{"agent_id":"a","standing":[],"one_shot":[],"sequence":0,
                      "granted_at":"t","extra":1}"#;
        assert!(serde_json::from_str::<WorkGrant>(json).is_err());
    }

    // ── 工作参数(`spec`)的编码 ──

    fn spec() -> WorkSpec {
        WorkSpec {
            units: vec![
                WorkSpecUnit {
                    unit_id: "mac-host-metrics".to_string(),
                    capability: "collect_metrics".to_string(),
                    rule_ref: "agent_uplink".to_string(),
                    requires_privilege: "none".to_string(),
                    sources: vec![WorkSpecSource {
                        kind: "MetricInterval".to_string(),
                        target: "15s".to_string(),
                        multiline: "none".to_string(),
                    }],
                },
                WorkSpecUnit {
                    unit_id: "mac-privacy-tcc".to_string(),
                    capability: "collect_logs".to_string(),
                    rule_ref: "macos/tcc".to_string(),
                    requires_privilege: "fda".to_string(),
                    sources: vec![
                        WorkSpecSource {
                            kind: "Exporter".to_string(),
                            target: "sqlite-snapshot(TCC.db)".to_string(),
                            multiline: "none".to_string(),
                        },
                        WorkSpecSource {
                            kind: "FileGlob".to_string(),
                            // 显式单路径:采集端今天只能执行这种(通配未实现)。
                            target: "/var/log/tccd.log".to_string(),
                            // 多行格式是这条日志的固有属性,跟着来源走。
                            multiline: "indented".to_string(),
                        },
                    ],
                },
            ],
        }
    }

    #[test]
    fn spec_round_trips_and_reports_what_agentd_can_do() {
        let encoded = spec().encode().expect("encode");
        let decoded = WorkSpec::parse(&encoded).expect("parse");
        assert_eq!(decoded, spec());

        assert!(decoded.collects_logs());
        // 取了最密的周期:要得最急的才是上送频率的下界。
        assert_eq!(decoded.metric_interval_seconds(), Some(15));
        // 可本地 tail 的来源挑得出来,接不了的来源也报得出来(而不是当没看见)。
        let files = decoded.units[1].file_sources();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].target, "/var/log/tccd.log");
        assert_eq!(files[0].multiline, "indented");
        assert_eq!(decoded.units[1].unsupported_sources().len(), 1);
        assert_eq!(decoded.units[1].unsupported_sources()[0].kind, "Exporter");
    }

    #[test]
    fn a_glob_file_source_counts_as_unsupported() {
        // 类型是 FileGlob("可 tail 的文件")不等于可采:**目标形态**也得是采集端能执行的。
        // 否则网关会把一个根本采不到的面报成“可采”,而 agentd 那边只会报“路径不存在”。
        let unit = WorkSpecUnit {
            unit_id: "mac-wifi".to_string(),
            capability: "collect_logs".to_string(),
            rule_ref: String::new(),
            requires_privilege: "root".to_string(),
            sources: vec![WorkSpecSource {
                kind: "FileGlob".to_string(),
                target: "/var/log/wifi.log*".to_string(),
                multiline: "none".to_string(),
            }],
        };
        assert_eq!(unit.file_sources().len(), 1, "它仍是一条文件来源");
        assert_eq!(unit.unsupported_sources().len(), 1, "但今天采不了");
        assert!(!unit.has_executable_source());
    }

    #[test]
    fn a_source_without_multiline_decodes_as_single_line() {
        // 老网关发来的 spec 没有 `multiline`:默认必须是一行一条。
        // 取错默认值会把多行日志**粘**成一条 —— 那是无声的内容损坏,不是格式问题。
        let json = r#"{"units":[{"unit_id":"u","capability":"collect_logs","sources":[{"kind":"FileGlob","target":"/a/*"}]}]}"#;
        let spec = WorkSpec::parse(json).expect("parse");
        assert_eq!(spec.units[0].sources[0].multiline, "none");
        // 再编码出去会把默认值写实:新网关发的 spec 字段总是齐的。
        assert_eq!(
            spec.encode().expect("encode"),
            r#"{"units":[{"unit_id":"u","capability":"collect_logs","rule_ref":"","requires_privilege":"","sources":[{"kind":"FileGlob","target":"/a/*","multiline":"none"}]}]}"#
        );
    }

    #[test]
    fn a_broken_spec_is_an_error_not_an_empty_work() {
        // 参数坏了与「没事可做」是两回事:前者要人去看,后者什么都不用做。
        assert!(WorkSpec::parse("mac-host-metrics").is_err());
        assert!(WorkSpec::parse("{").is_err());
        // 空工作本身是合法的(能表示「这个面暂时没东西可采」)。
        assert!(WorkSpec::parse(r#"{"units":[]}"#).unwrap().units.is_empty());
        assert_eq!(
            WorkSpec::parse(r#"{"units":[]}"#)
                .unwrap()
                .metric_interval_seconds(),
            None
        );
    }

    #[test]
    fn interval_parsing_accepts_seconds_and_minutes_only() {
        assert_eq!(parse_interval_seconds("15s"), Some(15));
        assert_eq!(parse_interval_seconds(" 5s "), Some(5));
        assert_eq!(parse_interval_seconds("5m"), Some(300));
        // 认不出来的回 None(调用方回退到自己的默认值),而不是静默当 0。
        assert_eq!(parse_interval_seconds("15"), None);
        assert_eq!(parse_interval_seconds("0s"), None);
        assert_eq!(parse_interval_seconds(""), None);
    }

    #[test]
    fn interval_parsing_never_overflows_on_huge_values() {
        // 契约是「解析不了返回 None」,不是 panic:溢出在 debug 下 panic、release 下回绕成一个
        // 错误的间隔 —— 两个后果都不能接受。这是两侧都要解析的字节,输入可能来自远端。
        assert_eq!(parse_interval_seconds("922337203685477580m"), None);
        assert_eq!(parse_interval_seconds("153722867280912931m"), None);
        // 秒分支本来就靠 parse 失败兜底。
        assert_eq!(parse_interval_seconds("99999999999999999999s"), None);
        // 合法但很大的分钟值仍要算得出来(不误伤)。
        assert_eq!(parse_interval_seconds("600m"), Some(36_000));
    }

    #[test]
    fn metric_interval_ignores_unparseable_targets_instead_of_treating_them_as_zero() {
        // 认不出的周期不能静默当 0(0 会被当成「无限密」),而要如实跳过,只取能解析的最密值。
        let unit = |target: &str| WorkSpecUnit {
            unit_id: "u".to_string(),
            capability: "collect_metrics".to_string(),
            rule_ref: String::new(),
            requires_privilege: String::new(),
            sources: vec![WorkSpecSource {
                kind: "MetricInterval".to_string(),
                target: target.to_string(),
                multiline: "none".to_string(),
            }],
        };
        let mixed = WorkSpec {
            units: vec![unit("garbage"), unit("30s")],
        };
        assert_eq!(mixed.metric_interval_seconds(), Some(30));

        let all_bad = WorkSpec {
            units: vec![unit("nope")],
        };
        assert_eq!(all_bad.metric_interval_seconds(), None);
    }
}