Skip to main content

lenso_service/
extraction_console_projection.rs

1use crate::extraction_input_digest;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use utoipa::ToSchema;
6
7pub const EXTRACTION_CONSOLE_PROJECTION_PROTOCOL: &str = "lenso.extraction-console.v1";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
10#[serde(rename_all = "camelCase")]
11pub struct ExtractionConsoleArtifacts {
12    pub readiness: Option<Value>,
13    pub plan: Option<Value>,
14    #[serde(default)]
15    pub phase_artifacts: Vec<Value>,
16    pub authority: Option<Value>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum ExtractionConsoleState {
22    Planned,
23    Preparing,
24    Blocked,
25    Quiesced,
26    Provisional,
27    RolledBack,
28    Committed,
29    PostCommitRollbackBlocked,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
33#[serde(rename_all = "camelCase")]
34pub struct ExtractionConsoleAuthority {
35    pub kind: String,
36    pub owner_id: String,
37    pub revision: String,
38}
39
40impl Default for ExtractionConsoleAuthority {
41    fn default() -> Self {
42        Self {
43            kind: "unknown".into(),
44            owner_id: "unknown".into(),
45            revision: "unknown".into(),
46        }
47    }
48}
49
50#[derive(
51    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
52)]
53#[serde(rename_all = "camelCase")]
54pub struct ExtractionConsoleBlocker {
55    pub code: String,
56    pub subject: String,
57    pub detail: String,
58    #[serde(default)]
59    pub next_actions: Vec<String>,
60    pub artifact_id: String,
61}
62
63#[derive(
64    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
65)]
66#[serde(rename_all = "camelCase")]
67pub struct ExtractionConsoleEvidence {
68    pub kind: String,
69    pub subject: String,
70    pub digest: String,
71    pub detail: String,
72    pub artifact_id: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
76#[serde(rename_all = "camelCase")]
77pub struct ExtractionConsoleTimelineEntry {
78    pub phase_id: String,
79    pub kind: String,
80    pub state: String,
81    pub artifact_id: String,
82    #[serde(default)]
83    pub blockers: Vec<ExtractionConsoleBlocker>,
84    #[serde(default)]
85    pub evidence: Vec<ExtractionConsoleEvidence>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
89#[serde(rename_all = "camelCase")]
90pub struct ExtractionConsoleApprovalBoundary {
91    pub boundary_id: String,
92    pub phase_id: String,
93    pub action: String,
94    pub reason: String,
95    #[serde(default)]
96    pub required_pins: Vec<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
100#[serde(rename_all = "camelCase")]
101pub struct ExtractionConsoleProjection {
102    pub protocol: String,
103    pub projection_digest: String,
104    pub state: ExtractionConsoleState,
105    pub plan_id: Option<String>,
106    pub plan_digest: Option<String>,
107    pub readiness_summary: String,
108    pub current_authority: ExtractionConsoleAuthority,
109    #[serde(default)]
110    pub timeline: Vec<ExtractionConsoleTimelineEntry>,
111    #[serde(default)]
112    pub blockers: Vec<ExtractionConsoleBlocker>,
113    #[serde(default)]
114    pub evidence: Vec<ExtractionConsoleEvidence>,
115    #[serde(default)]
116    pub approval_boundaries: Vec<ExtractionConsoleApprovalBoundary>,
117    pub read_only: bool,
118    #[serde(default)]
119    pub apply_actions: Vec<String>,
120    pub protected_workflow: String,
121}
122
123#[must_use]
124pub fn project_extraction_console(
125    mut artifacts: ExtractionConsoleArtifacts,
126) -> ExtractionConsoleProjection {
127    let plan_id = artifacts.plan.as_ref().and_then(|v| text(v, "planId"));
128    let plan_digest = artifacts.plan.as_ref().and_then(|v| text(v, "planDigest"));
129    let readiness_summary = match artifacts.readiness.as_ref() {
130        None => "Readiness evidence has not been recorded.".to_owned(),
131        Some(v) if v.get("ready").and_then(Value::as_bool) == Some(true) => {
132            "Extraction readiness passed with no blocking findings.".to_owned()
133        }
134        Some(v) => format!(
135            "Extraction readiness is blocked by {} finding(s).",
136            array(v, "findings").len()
137        ),
138    };
139    let current_authority = artifacts
140        .authority
141        .as_ref()
142        .map(|v| ExtractionConsoleAuthority {
143            kind: text(v, "kind").unwrap_or_else(|| "unknown".into()),
144            owner_id: text(v, "ownerId").unwrap_or_else(|| "unknown".into()),
145            revision: text(v, "revision").unwrap_or_else(|| "unknown".into()),
146        })
147        .unwrap_or_default();
148    let mut timeline = planned_timeline(artifacts.plan.as_ref());
149    let mut blockers = blockers_from(artifacts.readiness.as_ref(), "readiness", "findings");
150    let mut evidence = Vec::new();
151    for artifact in &artifacts.phase_artifacts {
152        let id = artifact_id(artifact);
153        let mut phase_blockers = blockers_from(Some(artifact), &id, "issues");
154        phase_blockers.extend(blockers_from(Some(artifact), &id, "errors"));
155        let phase_evidence = evidence_from(artifact, &id);
156        blockers.extend(phase_blockers.iter().cloned());
157        evidence.extend(phase_evidence.iter().cloned());
158        timeline.push(ExtractionConsoleTimelineEntry {
159            phase_id: phase_for(protocol(artifact)).into(),
160            kind: protocol(artifact).into(),
161            state: status(artifact).into(),
162            artifact_id: id,
163            blockers: phase_blockers,
164            evidence: phase_evidence,
165        });
166    }
167    timeline.sort_by_key(|entry| (phase_order(&entry.phase_id), entry.artifact_id.clone()));
168    blockers.sort();
169    blockers.dedup();
170    evidence.sort();
171    evidence.dedup();
172    let state = state(&artifacts, !blockers.is_empty());
173    artifacts.phase_artifacts.sort_by_key(artifact_id);
174    let mut projection = ExtractionConsoleProjection {
175        protocol: EXTRACTION_CONSOLE_PROJECTION_PROTOCOL.into(),
176        projection_digest: String::new(),
177        state,
178        plan_id,
179        plan_digest,
180        readiness_summary,
181        current_authority,
182        timeline,
183        blockers,
184        evidence,
185        approval_boundaries: approvals(artifacts.plan.as_ref()),
186        read_only: true,
187        apply_actions: vec![],
188        protected_workflow: "lenso service extract".into(),
189    };
190    projection.projection_digest = digest(&projection);
191    projection
192}
193
194/// Persist one authoritative workflow artifact for read-only operator projection.
195pub async fn record_extraction_artifact(
196    pool: &sqlx::PgPool,
197    plan_id: &str,
198    artifact: &Value,
199) -> Result<(), sqlx::Error> {
200    let persisted = sqlx::query(
201        r#"
202        insert into platform.extraction_artifacts
203            (plan_id, artifact_id, protocol, artifact_digest, artifact_json)
204        values ($1, $2, $3, $4, $5)
205        on conflict (plan_id, artifact_id, artifact_digest) do nothing
206        "#,
207    )
208    .bind(plan_id)
209    .bind(artifact_id(artifact))
210    .bind(protocol(artifact))
211    .bind(digest(artifact))
212    .bind(artifact)
213    .execute(pool)
214    .await?;
215    let _ = persisted;
216    Ok(())
217}
218
219/// Load persisted artifacts and evaluate the backend-owned projection.
220pub async fn load_extraction_console_projection(
221    pool: &sqlx::PgPool,
222    requested_plan_id: Option<&str>,
223) -> Result<ExtractionConsoleProjection, sqlx::Error> {
224    let exists = sqlx::query_scalar::<_, Option<String>>(
225        "select to_regclass('platform.extraction_artifacts')::text",
226    )
227    .fetch_one(pool)
228    .await?
229    .is_some();
230    if !exists {
231        return Ok(empty_projection());
232    }
233    let plan_id = match requested_plan_id {
234        Some(plan_id) => Some(plan_id.to_owned()),
235        None => sqlx::query_scalar::<_, String>(
236            "select plan_id from platform.extraction_artifacts order by recorded_at desc, plan_id desc limit 1",
237        )
238        .fetch_optional(pool)
239        .await?,
240    };
241    let Some(plan_id) = plan_id else {
242        return Ok(empty_projection());
243    };
244    let rows = sqlx::query_as::<_, (String, Value)>(
245        "select protocol, artifact_json from platform.extraction_artifacts where plan_id = $1 order by recorded_at, artifact_id",
246    )
247    .bind(plan_id)
248    .fetch_all(pool)
249    .await?;
250    let mut artifacts = ExtractionConsoleArtifacts {
251        readiness: None,
252        plan: None,
253        phase_artifacts: Vec::new(),
254        authority: None,
255    };
256    for (protocol, artifact) in rows {
257        match protocol.as_str() {
258            "lenso.extraction-readiness-report.v1" => artifacts.readiness = Some(artifact),
259            "lenso.extraction-plan.v1" => artifacts.plan = Some(artifact),
260            "lenso.extraction-authority.v1" => artifacts.authority = Some(artifact),
261            _ => artifacts.phase_artifacts.push(artifact),
262        }
263    }
264    Ok(project_extraction_console(artifacts))
265}
266
267/// Load one persisted workflow artifact by its stable identifier.
268pub async fn load_extraction_artifact(
269    pool: &sqlx::PgPool,
270    plan_id: &str,
271    artifact_id: &str,
272) -> Result<Option<Value>, sqlx::Error> {
273    sqlx::query_scalar(
274        "select artifact_json from platform.extraction_artifacts where plan_id = $1 and artifact_id = $2 order by recorded_at desc limit 1",
275    )
276    .bind(plan_id)
277    .bind(artifact_id)
278    .fetch_optional(pool)
279    .await
280}
281
282fn empty_projection() -> ExtractionConsoleProjection {
283    project_extraction_console(ExtractionConsoleArtifacts {
284        readiness: None,
285        plan: None,
286        phase_artifacts: Vec::new(),
287        authority: None,
288    })
289}
290
291fn state(a: &ExtractionConsoleArtifacts, blocked: bool) -> ExtractionConsoleState {
292    let has = |p: &str, s: &str| {
293        a.phase_artifacts
294            .iter()
295            .any(|v| protocol(v) == p && status(v) == s)
296    };
297    if a.phase_artifacts.iter().rev().any(|v| {
298        protocol(v) == "lenso.extraction-authority-commit.v1"
299            && v.get("fastRollbackBlocked").and_then(Value::as_bool) == Some(true)
300    }) {
301        ExtractionConsoleState::PostCommitRollbackBlocked
302    } else if has("lenso.extraction-authority-commit.v1", "committed") {
303        ExtractionConsoleState::Committed
304    } else if let Some(cutover) = a
305        .phase_artifacts
306        .iter()
307        .rev()
308        .find(|v| protocol(v) == "lenso.extraction-provisional-cutover.v1")
309    {
310        if status(cutover) == "rolled_back" {
311            ExtractionConsoleState::RolledBack
312        } else {
313            ExtractionConsoleState::Provisional
314        }
315    } else if has("lenso.extraction-quiescence.v1", "quiesced") {
316        ExtractionConsoleState::Quiesced
317    } else if blocked || a.phase_artifacts.iter().any(|v| status(v) == "blocked") {
318        ExtractionConsoleState::Blocked
319    } else if a.phase_artifacts.is_empty() {
320        ExtractionConsoleState::Planned
321    } else {
322        ExtractionConsoleState::Preparing
323    }
324}
325
326fn planned_timeline(plan: Option<&Value>) -> Vec<ExtractionConsoleTimelineEntry> {
327    let plan_artifact_id = plan.map(artifact_id).unwrap_or_else(|| "plan".into());
328    plan.map(|v| array(v, "phases"))
329        .unwrap_or_default()
330        .into_iter()
331        .map(|v| ExtractionConsoleTimelineEntry {
332            phase_id: text(v, "phaseId").unwrap_or_else(|| "unknown-phase".into()),
333            kind: text(v, "kind").unwrap_or_else(|| "unknown".into()),
334            state: "planned".into(),
335            artifact_id: plan_artifact_id.clone(),
336            blockers: vec![],
337            evidence: vec![],
338        })
339        .collect()
340}
341
342fn blockers_from(
343    value: Option<&Value>,
344    artifact_id: &str,
345    field: &str,
346) -> Vec<ExtractionConsoleBlocker> {
347    value
348        .map(|v| array(v, field))
349        .unwrap_or_default()
350        .into_iter()
351        .map(|v| ExtractionConsoleBlocker {
352            code: text(v, "code")
353                .or_else(|| text(v, "issueCode"))
354                .unwrap_or_else(|| "blocked".into()),
355            subject: text(v, "subject").unwrap_or_else(|| artifact_id.into()),
356            detail: text(v, "detail")
357                .or_else(|| text(v, "message"))
358                .unwrap_or_else(|| "Extraction phase is blocked.".into()),
359            next_actions: texts(v, "nextActions"),
360            artifact_id: artifact_id.into(),
361        })
362        .collect()
363}
364
365fn evidence_from(value: &Value, artifact_id: &str) -> Vec<ExtractionConsoleEvidence> {
366    array(value, "evidence")
367        .into_iter()
368        .map(|v| ExtractionConsoleEvidence {
369            kind: text(v, "kind").unwrap_or_else(|| "evidence".into()),
370            subject: text(v, "subject").unwrap_or_else(|| artifact_id.into()),
371            digest: text(v, "digest").unwrap_or_else(|| digest(v)),
372            detail: text(v, "detail").unwrap_or_default(),
373            artifact_id: artifact_id.into(),
374        })
375        .collect()
376}
377
378fn approvals(plan: Option<&Value>) -> Vec<ExtractionConsoleApprovalBoundary> {
379    let mut out = plan
380        .map(|v| array(v, "phases"))
381        .unwrap_or_default()
382        .into_iter()
383        .filter_map(|phase| {
384            let b = phase.get("approvalBoundary")?;
385            Some(ExtractionConsoleApprovalBoundary {
386                boundary_id: text(b, "boundaryId")?,
387                phase_id: text(b, "phaseId")
388                    .or_else(|| text(phase, "phaseId"))
389                    .unwrap_or_default(),
390                action: text(b, "action").unwrap_or_default(),
391                reason: text(b, "reason").unwrap_or_default(),
392                required_pins: texts(b, "requiredPins"),
393            })
394        })
395        .collect::<Vec<_>>();
396    out.sort_by(|l, r| l.boundary_id.cmp(&r.boundary_id));
397    out
398}
399
400fn artifact_id(v: &Value) -> String {
401    [
402        "commitId",
403        "evidenceId",
404        "cutoverId",
405        "quiescenceId",
406        "verificationId",
407        "reconciliationId",
408        "runId",
409        "planId",
410    ]
411    .into_iter()
412    .find_map(|f| text(v, f))
413    .unwrap_or_else(|| digest(v))
414}
415fn protocol(v: &Value) -> &str {
416    v.get("protocol")
417        .and_then(Value::as_str)
418        .unwrap_or("unknown")
419}
420fn status(v: &Value) -> &str {
421    v.get("status")
422        .and_then(Value::as_str)
423        .unwrap_or("recorded")
424}
425fn phase_for(p: &str) -> &str {
426    match p {
427        "lenso.extraction-run.v1" => "03-destination-expansion",
428        "lenso.extraction-backfill.v1" => "04-backfill",
429        "lenso.extraction-reconciliation.v1" => "05-reconciliation",
430        "lenso.extraction-verification.v1" => "06-verification",
431        "lenso.extraction-quiescence.v1" => "07-drain",
432        "lenso.extraction-provisional-cutover.v1" => "08-provisional-cutover",
433        "lenso.extraction-authority-commit.v1" => "09-rollback-or-commit",
434        "lenso.extraction-candidate-health.v1" => "09-rollback-or-commit",
435        _ => "unknown-phase",
436    }
437}
438fn phase_order(v: &str) -> u16 {
439    v.split('-')
440        .next()
441        .and_then(|x| x.parse().ok())
442        .unwrap_or(u16::MAX)
443}
444fn text(v: &Value, f: &str) -> Option<String> {
445    v.get(f).and_then(Value::as_str).map(str::to_owned)
446}
447fn texts(v: &Value, f: &str) -> Vec<String> {
448    array(v, f)
449        .into_iter()
450        .filter_map(Value::as_str)
451        .map(str::to_owned)
452        .collect()
453}
454fn array<'a>(v: &'a Value, f: &str) -> Vec<&'a Value> {
455    v.get(f)
456        .and_then(Value::as_array)
457        .map(|x| x.iter().collect())
458        .unwrap_or_default()
459}
460fn digest(value: &impl Serialize) -> String {
461    extraction_input_digest(&serde_json::to_vec(value).expect("projection values serialize"))
462}