feat(workflows): M1 — schema + definition validation + declarative reconcile
First milestone of the v1.2 Workflows track (blueprint §9.1/§9.2): the durable
DAG engine's foundation — the definition model, its validation, and the
declarative `apply` reconcile path. No execution yet (the orchestrator is M2).
- Migration 0071_workflows: `workflows` (owner-polymorphic like scripts, app-
owned in M1), `workflow_runs`, and `workflow_run_steps` (the per-step
competing-consumer lease table the M2 orchestrator will claim). Schema golden
reblessed.
- `shared::workflow`: the `WorkflowDefinition` / `WorkflowStepDef` DTOs (shared
by the CLI, apply, and the future orchestrator) + run/step status enums.
- Pure, DB-free `workflow_template` (input `{{ input.x }}` / `{{ steps.a.output.y }}`
resolution, type-preserving) and `workflow_expr` (a small safe JSON-predicate
evaluator for `when` — keeps manager-core free of a scripting engine).
- `workflow_repo`: read trait + reconcile tx free-fns (insert/update/delete).
- apply_service: `BundleWorkflow` + `Plan.workflows` + `CurrentState.workflows`
+ `ApplyReport.workflows_*`; server-side `validate_workflow_definition`
(unique/acyclic steps via topological sort, deps exist, function XOR workflow,
`when`/template parse) rejecting on a group node; `diff_workflows` by
lower(name) (Update on a definition change) + in-tx reconcile.
- CLI: `[[workflows]]` + `[[workflows.steps]]` manifest structs → wire bundle;
plan/apply render + report counts.
- Tests: 16 manager-core lib tests (template, expr, definition validation) +
the `workflows` CLI journey (apply → NoOp → prune; cyclic DAG rejected).
Deferred to later milestones: the orchestrator worker (M2), conditional/template
runtime wiring (M3), nested sub-workflows (M4), the SDK/API/CLI run surface
(M5), the dashboard (M6). The `group_id` column + run/step tables ship now so
those slot in without a migration churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
229
crates/manager-core/src/workflow_repo.rs
Normal file
229
crates/manager-core/src/workflow_repo.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
//! Persistence for workflow definitions (v1.2 Workflows).
|
||||
//!
|
||||
//! The `workflows` table (0071) is owner-polymorphic like `scripts`; M1 authors
|
||||
//! **app-owned** workflows only (the `group_id` column ships unused). The read
|
||||
//! trait backs the admin/CLI surface; the `*_tx` free-fns are used by the
|
||||
//! declarative `apply` reconcile engine (Create/Update/Delete by `lower(name)`,
|
||||
//! all in one transaction) — mirroring `trigger_repo::insert_trigger_tx`.
|
||||
//!
|
||||
//! Run/step state (`workflow_runs`, `workflow_run_steps`) is added in M2 with
|
||||
//! the orchestrator.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use picloud_shared::workflow::WorkflowDefinition;
|
||||
use picloud_shared::{AppId, WorkflowId};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowRepoError {
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("workflow definition (de)serialization failed: {0}")]
|
||||
Serde(String),
|
||||
}
|
||||
|
||||
/// A stored workflow definition (app-owned in M1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workflow {
|
||||
pub id: WorkflowId,
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub definition: WorkflowDefinition,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Raw row shape (definition decoded separately).
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WorkflowRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
name: String,
|
||||
definition: serde_json::Value,
|
||||
enabled: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<WorkflowRow> for Workflow {
|
||||
type Error = WorkflowRepoError;
|
||||
fn try_from(r: WorkflowRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
definition: serde_json::from_value(r.definition)
|
||||
.map_err(|e| WorkflowRepoError::Serde(e.to_string()))?,
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS: &str = "id, app_id, name, definition, enabled, created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkflowRepo: Send + Sync {
|
||||
/// All workflows owned by `app_id`, ordered by name.
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Workflow>, WorkflowRepoError>;
|
||||
/// A single app-owned workflow by case-insensitive name.
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Workflow>, WorkflowRepoError>;
|
||||
/// A single workflow by id (app-scoped for isolation).
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
id: WorkflowId,
|
||||
) -> Result<Option<Workflow>, WorkflowRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresWorkflowRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresWorkflowRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowRepo for PostgresWorkflowRepo {
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Workflow>, WorkflowRepoError> {
|
||||
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Workflow>, WorkflowRepoError> {
|
||||
let row: Option<WorkflowRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND LOWER(name) = LOWER($2)"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
id: WorkflowId,
|
||||
) -> Result<Option<Workflow>, WorkflowRepoError> {
|
||||
let row: Option<WorkflowRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pool-based read of an app's workflows (used by `apply`'s `load_current`,
|
||||
/// which reads several markers directly off the pool).
|
||||
pub async fn list_workflows_for_app(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Workflow>, WorkflowRepoError> {
|
||||
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
// ---- reconcile tx free-fns (used by apply_service) ------------------------
|
||||
|
||||
/// Load an app's workflows inside the apply transaction (so the diff sees a
|
||||
/// snapshot consistent with the writes).
|
||||
pub async fn list_workflows_for_app_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Workflow>, WorkflowRepoError> {
|
||||
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
|
||||
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
/// Insert an app-owned workflow.
|
||||
pub async fn insert_workflow_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
definition: &WorkflowDefinition,
|
||||
enabled: bool,
|
||||
) -> Result<WorkflowId, WorkflowRepoError> {
|
||||
let def =
|
||||
serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?;
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO workflows (app_id, name, definition, enabled) \
|
||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(def)
|
||||
.bind(enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
/// Update a workflow's definition + enabled flag (name/identity unchanged).
|
||||
pub async fn update_workflow_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: WorkflowId,
|
||||
definition: &WorkflowDefinition,
|
||||
enabled: bool,
|
||||
) -> Result<(), WorkflowRepoError> {
|
||||
let def =
|
||||
serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?;
|
||||
sqlx::query(
|
||||
"UPDATE workflows SET definition = $2, enabled = $3, updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(def)
|
||||
.bind(enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a workflow (prune). RESTRICTs if runs reference it — a workflow with
|
||||
/// history is kept; this is surfaced as an error to the reconcile caller.
|
||||
pub async fn delete_workflow_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: WorkflowId,
|
||||
) -> Result<(), WorkflowRepoError> {
|
||||
sqlx::query("DELETE FROM workflows WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user