Skip to main content

imp_core/mana_next/
ledger.rs

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}