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
194pub 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
219pub 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
267pub 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}