1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::workflow::{AutonomyMode, RiskLevel, WorkflowType};
6
7#[derive(Debug, Clone, PartialEq, Eq, Default)]
8pub struct WorkflowLedgerUpdate {
9 pub run_id: Option<String>,
10 pub status: Option<LedgerStatus>,
11 pub blockers: Vec<String>,
12 pub verification_refs: Vec<String>,
13 pub evidence_refs: Vec<String>,
14 pub closeout_status: Option<CloseoutStatus>,
15 pub contract_artifact: Option<ArtifactRef>,
16}
17
18pub fn workflow_record_from_contract(
19 id: impl Into<String>,
20 contract: &crate::workflow::WorkflowContract,
21 update: WorkflowLedgerUpdate,
22) -> WorkflowRecord {
23 let id = id.into();
24 let title = contract.title.clone().unwrap_or_else(|| {
25 contract
26 .objective
27 .lines()
28 .next()
29 .unwrap_or_default()
30 .to_owned()
31 });
32
33 WorkflowRecord {
34 id,
35 title,
36 status: update.status.unwrap_or(LedgerStatus::Open),
37 workflow_type: contract.workflow_type,
38 risk_level: contract.risk_level,
39 autonomy_mode: contract.autonomy_mode,
40 parent: contract.parent_workflow_ref.clone(),
41 contract_ref: Some(WorkflowContractRef {
42 run_id: update.run_id.clone(),
43 artifact: update.contract_artifact,
44 }),
45 acceptance: contract.closeout_criteria.criteria.clone(),
46 closeout_criteria: contract.closeout_criteria.criteria.clone(),
47 verification_refs: update.verification_refs,
48 evidence_refs: update.evidence_refs,
49 decision_refs: Vec::new(),
50 note_refs: Vec::new(),
51 child_run_refs: Vec::new(),
52 blockers: update.blockers,
53 final_status: update.closeout_status,
54 }
55}
56
57pub fn apply_workflow_ledger_update(record: &mut WorkflowRecord, update: WorkflowLedgerUpdate) {
58 if let Some(status) = update.status {
59 record.status = status;
60 }
61 if let Some(closeout_status) = update.closeout_status {
62 record.final_status = Some(closeout_status);
63 }
64 if let Some(contract_artifact) = update.contract_artifact {
65 let contract_ref = record
66 .contract_ref
67 .get_or_insert_with(WorkflowContractRef::default);
68 contract_ref.artifact = Some(contract_artifact);
69 }
70 if let Some(run_id) = update.run_id {
71 let contract_ref = record
72 .contract_ref
73 .get_or_insert_with(WorkflowContractRef::default);
74 contract_ref.run_id = Some(run_id);
75 }
76 extend_unique(&mut record.blockers, update.blockers);
77 extend_unique(&mut record.verification_refs, update.verification_refs);
78 extend_unique(&mut record.evidence_refs, update.evidence_refs);
79}
80
81fn extend_unique(target: &mut Vec<String>, values: Vec<String>) {
82 for value in values {
83 if !target.contains(&value) {
84 target.push(value);
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "kebab-case", tag = "kind")]
91pub enum LedgerRecord {
92 Workflow(WorkflowRecord),
93 Task(TaskRecord),
94 Decision(DecisionRecord),
95 Verification(VerificationRecord),
96 Evidence(EvidenceRecord),
97 Note(NoteRecord),
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(default)]
102pub struct WorkflowRecord {
103 pub id: String,
104 pub title: String,
105 pub status: LedgerStatus,
106 pub workflow_type: WorkflowType,
107 pub risk_level: RiskLevel,
108 pub autonomy_mode: AutonomyMode,
109 pub parent: Option<String>,
110 pub contract_ref: Option<WorkflowContractRef>,
111 pub acceptance: Vec<String>,
112 pub closeout_criteria: Vec<String>,
113 pub verification_refs: Vec<String>,
114 pub evidence_refs: Vec<String>,
115 pub decision_refs: Vec<String>,
116 pub note_refs: Vec<String>,
117 pub child_run_refs: Vec<ChildRunRef>,
118 pub blockers: Vec<String>,
119 pub final_status: Option<CloseoutStatus>,
120}
121
122impl WorkflowRecord {
123 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
124 Self {
125 id: id.into(),
126 title: title.into(),
127 ..Self::default()
128 }
129 }
130}
131
132impl Default for WorkflowRecord {
133 fn default() -> Self {
134 Self {
135 id: String::new(),
136 title: String::new(),
137 status: LedgerStatus::Open,
138 workflow_type: WorkflowType::AdHoc,
139 risk_level: RiskLevel::Unknown,
140 autonomy_mode: AutonomyMode::Safe,
141 parent: None,
142 contract_ref: None,
143 acceptance: Vec::new(),
144 closeout_criteria: Vec::new(),
145 verification_refs: Vec::new(),
146 evidence_refs: Vec::new(),
147 decision_refs: Vec::new(),
148 note_refs: Vec::new(),
149 child_run_refs: Vec::new(),
150 blockers: Vec::new(),
151 final_status: None,
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct TaskRecord {
159 pub id: String,
160 pub workflow_id: Option<String>,
161 pub title: String,
162 pub status: LedgerStatus,
163 pub role: Option<String>,
164 pub assignee: Option<String>,
165 pub dependencies: Vec<String>,
166 pub requires: Vec<String>,
167 pub produces: Vec<String>,
168 pub verification_refs: Vec<String>,
169 pub evidence_refs: Vec<String>,
170 pub blockers: Vec<String>,
171 pub closeout_status: Option<CloseoutStatus>,
172}
173
174impl TaskRecord {
175 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
176 Self {
177 id: id.into(),
178 title: title.into(),
179 ..Self::default()
180 }
181 }
182}
183
184impl Default for TaskRecord {
185 fn default() -> Self {
186 Self {
187 id: String::new(),
188 workflow_id: None,
189 title: String::new(),
190 status: LedgerStatus::Open,
191 role: None,
192 assignee: None,
193 dependencies: Vec::new(),
194 requires: Vec::new(),
195 produces: Vec::new(),
196 verification_refs: Vec::new(),
197 evidence_refs: Vec::new(),
198 blockers: Vec::new(),
199 closeout_status: None,
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(default)]
206pub struct DecisionRecord {
207 pub id: String,
208 pub workflow_id: Option<String>,
209 pub question: String,
210 pub status: DecisionStatus,
211 pub options: Vec<String>,
212 pub outcome: Option<String>,
213 pub rationale: Option<String>,
214 pub blocks: Vec<String>,
215}
216
217impl Default for DecisionRecord {
218 fn default() -> Self {
219 Self {
220 id: String::new(),
221 workflow_id: None,
222 question: String::new(),
223 status: DecisionStatus::Open,
224 options: Vec::new(),
225 outcome: None,
226 rationale: None,
227 blocks: Vec::new(),
228 }
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(default)]
234pub struct VerificationRecord {
235 pub id: String,
236 pub workflow_id: Option<String>,
237 pub task_id: Option<String>,
238 pub name: Option<String>,
239 pub gate_type: VerificationGateType,
240 pub required: bool,
241 pub status: VerificationStatus,
242 pub command: Option<String>,
243 pub exit_code: Option<i32>,
244 pub artifact_refs: Vec<String>,
245}
246
247impl VerificationRecord {
248 pub fn required_command(id: impl Into<String>, command: impl Into<String>) -> Self {
249 Self {
250 id: id.into(),
251 gate_type: VerificationGateType::Command,
252 required: true,
253 command: Some(command.into()),
254 ..Self::default()
255 }
256 }
257}
258
259impl Default for VerificationRecord {
260 fn default() -> Self {
261 Self {
262 id: String::new(),
263 workflow_id: None,
264 task_id: None,
265 name: None,
266 gate_type: VerificationGateType::Manual,
267 required: true,
268 status: VerificationStatus::Pending,
269 command: None,
270 exit_code: None,
271 artifact_refs: Vec::new(),
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(default)]
278pub struct EvidenceRecord {
279 pub id: String,
280 pub workflow_id: Option<String>,
281 pub task_id: Option<String>,
282 pub run_id: Option<String>,
283 pub evidence_type: EvidenceType,
284 pub trust_label: Option<String>,
285 pub summary: String,
286 pub artifact: Option<ArtifactRef>,
287 pub produced_by: Option<String>,
288}
289
290impl Default for EvidenceRecord {
291 fn default() -> Self {
292 Self {
293 id: String::new(),
294 workflow_id: None,
295 task_id: None,
296 run_id: None,
297 evidence_type: EvidenceType::Other,
298 trust_label: None,
299 summary: String::new(),
300 artifact: None,
301 produced_by: None,
302 }
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
307#[serde(default)]
308pub struct NoteRecord {
309 pub id: String,
310 pub workflow_id: Option<String>,
311 pub task_id: Option<String>,
312 pub source: NoteSource,
313 pub trust_label: Option<String>,
314 pub body: String,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
318#[serde(default)]
319pub struct WorkflowContractRef {
320 pub run_id: Option<String>,
321 pub artifact: Option<ArtifactRef>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(default)]
326pub struct ArtifactRef {
327 pub id: Option<String>,
328 pub run_id: Option<String>,
329 pub kind: ArtifactKind,
330 pub path: PathBuf,
331 pub media_type: Option<String>,
332 pub sha256: Option<String>,
333 pub bytes: Option<u64>,
334}
335
336impl ArtifactRef {
337 pub fn new(kind: ArtifactKind, path: impl Into<PathBuf>) -> Self {
338 Self {
339 kind,
340 path: path.into(),
341 ..Self::default()
342 }
343 }
344}
345
346impl Default for ArtifactRef {
347 fn default() -> Self {
348 Self {
349 id: None,
350 run_id: None,
351 kind: ArtifactKind::Other,
352 path: PathBuf::new(),
353 media_type: None,
354 sha256: None,
355 bytes: None,
356 }
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
361#[serde(default)]
362pub struct ChildRunRef {
363 pub child_id: String,
364 pub role: Option<String>,
365 pub status: LedgerStatus,
366 pub workflow_id: Option<String>,
367 pub evidence_refs: Vec<String>,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
371#[serde(rename_all = "kebab-case")]
372pub enum LedgerStatus {
373 #[default]
374 Open,
375 Claimed,
376 Planned,
377 Executing,
378 WaitingForApproval,
379 Verifying,
380 Blocked,
381 Done,
382 DoneWithConcerns,
383 NeedsContext,
384 Cancelled,
385 Archived,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
389#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
390pub enum CloseoutStatus {
391 #[default]
392 Done,
393 DoneWithConcerns,
394 Blocked,
395 NeedsContext,
396 Cancelled,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
400#[serde(rename_all = "kebab-case")]
401pub enum DecisionStatus {
402 #[default]
403 Open,
404 Resolved,
405 Superseded,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
409#[serde(rename_all = "kebab-case")]
410pub enum VerificationGateType {
411 Command,
412 Diff,
413 Policy,
414 #[default]
415 Manual,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
419#[serde(rename_all = "kebab-case")]
420pub enum VerificationStatus {
421 #[default]
422 Pending,
423 Running,
424 Passed,
425 Failed,
426 Skipped,
427 Blocked,
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
431#[serde(rename_all = "kebab-case")]
432pub enum EvidenceType {
433 Trace,
434 EvidencePacket,
435 Diff,
436 TestOutput,
437 PolicyDecision,
438 ToolObservation,
439 ManualReview,
440 ChildResult,
441 EvalCandidate,
442 #[default]
443 Other,
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
447#[serde(rename_all = "kebab-case")]
448pub enum ArtifactKind {
449 Trace,
450 EvidencePacket,
451 Diff,
452 VerifyLog,
453 PolicyLog,
454 WorkflowContract,
455 #[default]
456 Other,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
460#[serde(rename_all = "kebab-case")]
461pub enum NoteSource {
462 User,
463 Agent,
464 Tool,
465 System,
466 #[default]
467 Unknown,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn mana_workflow_ledger_adapter_builds_record_from_contract() {
476 let contract = crate::workflow::WorkflowContract::implicit("Implement adapter")
477 .with_autonomy_mode(AutonomyMode::LocalAuto)
478 .with_mana_unit_ref("394.3.5");
479 let record = workflow_record_from_contract(
480 "394.3.5",
481 &contract,
482 WorkflowLedgerUpdate {
483 run_id: Some("run_1".into()),
484 status: Some(LedgerStatus::Executing),
485 blockers: vec!["waiting on schema".into()],
486 verification_refs: vec!["verify_1".into()],
487 evidence_refs: vec!["evidence_1".into()],
488 closeout_status: None,
489 contract_artifact: Some(ArtifactRef::new(
490 ArtifactKind::WorkflowContract,
491 ".imp/runs/run_1/workflow-contract.json",
492 )),
493 },
494 );
495
496 assert_eq!(record.id, "394.3.5");
497 assert_eq!(record.autonomy_mode, AutonomyMode::LocalAuto);
498 assert_eq!(record.status, LedgerStatus::Executing);
499 assert_eq!(record.blockers, vec!["waiting on schema"]);
500 assert_eq!(record.verification_refs, vec!["verify_1"]);
501 assert_eq!(record.evidence_refs, vec!["evidence_1"]);
502 assert_eq!(
503 record
504 .contract_ref
505 .as_ref()
506 .and_then(|r| r.run_id.as_deref()),
507 Some("run_1")
508 );
509 }
510
511 #[test]
512 fn mana_workflow_ledger_adapter_updates_record_without_duplicate_refs() {
513 let mut record = WorkflowRecord::new("394.3.5", "Adapter");
514 apply_workflow_ledger_update(
515 &mut record,
516 WorkflowLedgerUpdate {
517 status: Some(LedgerStatus::Blocked),
518 blockers: vec!["needs storage decision".into()],
519 verification_refs: vec!["verify_1".into()],
520 evidence_refs: vec!["evidence_1".into()],
521 ..WorkflowLedgerUpdate::default()
522 },
523 );
524 apply_workflow_ledger_update(
525 &mut record,
526 WorkflowLedgerUpdate {
527 status: Some(LedgerStatus::Done),
528 blockers: vec!["needs storage decision".into()],
529 verification_refs: vec!["verify_1".into(), "verify_2".into()],
530 evidence_refs: vec!["evidence_1".into()],
531 closeout_status: Some(CloseoutStatus::Done),
532 ..WorkflowLedgerUpdate::default()
533 },
534 );
535
536 assert_eq!(record.status, LedgerStatus::Done);
537 assert_eq!(record.final_status, Some(CloseoutStatus::Done));
538 assert_eq!(record.blockers, vec!["needs storage decision"]);
539 assert_eq!(record.verification_refs, vec!["verify_1", "verify_2"]);
540 assert_eq!(record.evidence_refs, vec!["evidence_1"]);
541 }
542
543 #[test]
544 fn mana_workflow_ledger_round_trips_workflow_record() {
545 let record = LedgerRecord::Workflow(WorkflowRecord {
546 id: "394.3".into(),
547 title: "Streamline mana".into(),
548 status: LedgerStatus::Executing,
549 workflow_type: WorkflowType::CodeChange,
550 autonomy_mode: AutonomyMode::LocalAuto,
551 verification_refs: vec!["verify_1".into()],
552 evidence_refs: vec!["evidence_1".into()],
553 child_run_refs: vec![ChildRunRef {
554 child_id: "child_1".into(),
555 role: Some("verifier".into()),
556 status: LedgerStatus::Done,
557 workflow_id: Some("394.3.child.1".into()),
558 evidence_refs: vec!["evidence_child_1".into()],
559 }],
560 final_status: Some(CloseoutStatus::Done),
561 ..WorkflowRecord::default()
562 });
563
564 let json = serde_json::to_string(&record).unwrap();
565 assert!(json.contains("workflow"));
566 let decoded: LedgerRecord = serde_json::from_str(&json).unwrap();
567 assert_eq!(decoded, record);
568 }
569
570 #[test]
571 fn mana_workflow_ledger_represents_task_decision_verification_evidence_and_note() {
572 let records = vec![
573 LedgerRecord::Task(TaskRecord::new("394.3.4", "Implement ledger types")),
574 LedgerRecord::Decision(DecisionRecord {
575 id: "dec_1".into(),
576 question: "Use sidecars?".into(),
577 options: vec!["frontmatter".into(), "sidecars".into()],
578 ..DecisionRecord::default()
579 }),
580 LedgerRecord::Verification(VerificationRecord::required_command(
581 "verify_1",
582 "cargo test -p imp-core mana_workflow_ledger",
583 )),
584 LedgerRecord::Evidence(EvidenceRecord {
585 id: "evidence_1".into(),
586 evidence_type: EvidenceType::EvidencePacket,
587 summary: "Evidence packet written".into(),
588 artifact: Some(ArtifactRef::new(
589 ArtifactKind::EvidencePacket,
590 ".imp/runs/run_1/evidence.md",
591 )),
592 ..EvidenceRecord::default()
593 }),
594 LedgerRecord::Note(NoteRecord {
595 id: "note_1".into(),
596 source: NoteSource::Agent,
597 body: "Compatibility mapping drafted".into(),
598 ..NoteRecord::default()
599 }),
600 ];
601
602 for record in records {
603 let value = serde_json::to_value(&record).unwrap();
604 let decoded: LedgerRecord = serde_json::from_value(value).unwrap();
605 assert_eq!(decoded, record);
606 }
607 }
608}