Workflow layer: gates, decisions, lineage audits, workflow test

This commit is contained in:
2026-05-06 18:54:55 +02:00
parent 3b5f96e159
commit f4f77b2eeb
11 changed files with 1037 additions and 25 deletions

View File

@@ -16,9 +16,13 @@ from kontextual_engine import (
TransformationRunStatus,
TransformationService,
ValidationError,
WorkflowExceptionKind,
WorkflowExceptionStatus,
WorkflowInputDefinition,
WorkflowInputKind,
WorkflowInvocation,
WorkflowReviewDecisionType,
WorkflowReviewStatus,
WorkflowRunStatus,
WorkflowService,
WorkflowStepDefinition,
@@ -225,6 +229,180 @@ def test_sqlite_workflow_templates_and_runs_survive_reinstantiation(tmp_path: Pa
assert reloaded.list_representations(asset_id="asset-single-output")[0].kind == RepresentationKind.DERIVED
def test_review_gate_pauses_output_then_continue_completes_and_reconstructs_lineage() -> None:
repo = InMemoryAssetRegistryRepository()
registry = AssetRegistryService(repo)
context = operation_context()
create_source_asset(registry, context, asset_id="asset-source")
workflow = WorkflowService(
repo,
transformation_service=TransformationService(repo, asset_service=registry),
)
workflow.register_template(review_gate_template(), context)
waiting = workflow.invoke_template(
WorkflowInvocation(
template_id="workflow-review",
inputs={"source": {"kind": "asset", "asset_id": "asset-source"}},
),
context,
)
assert waiting.success is False
assert waiting.run.status == WorkflowRunStatus.WAITING
assert waiting.run.step_runs[0].status == WorkflowStepRunStatus.WAITING
assert waiting.run.review_tasks[0].status == WorkflowReviewStatus.OPEN
assert waiting.run.exceptions[0].kind == WorkflowExceptionKind.REVIEW_REQUIRED
assert waiting.run.exceptions[0].status == WorkflowExceptionStatus.OPEN
assert workflow.list_review_tasks(workflow_run_id=waiting.run.run_id) == waiting.run.review_tasks
assert workflow.list_exception_queue(kind=WorkflowExceptionKind.REVIEW_REQUIRED) == waiting.run.exceptions
continued = workflow.record_review_decision(
waiting.run.run_id,
waiting.run.review_tasks[0].review_id,
WorkflowReviewDecisionType.CONTINUE,
context,
note="approved for release",
)
reconstruction = workflow.reconstruct_run(continued.run.run_id)
assert continued.success is True
assert continued.run.status == WorkflowRunStatus.COMPLETED
assert continued.run.review_tasks[0].status == WorkflowReviewStatus.CONTINUED
assert continued.run.exceptions[0].status == WorkflowExceptionStatus.RESOLVED
assert continued.run.output_asset_ids == ("asset-reviewed-output",)
assert len(reconstruction.transformation_runs) == 1
assert reconstruction.derived_lineage[0].output_asset_id == "asset-reviewed-output"
assert {
event.operation
for event in reconstruction.audit_events
} >= {
"workflow.review.requested",
"workflow.review.continue",
"derived_artifact.lineage.linked",
}
def test_review_decisions_can_reject_correct_retry_and_escalate_runs() -> None:
repo = InMemoryAssetRegistryRepository()
registry = AssetRegistryService(repo)
context = operation_context()
create_source_asset(registry, context, asset_id="asset-source")
workflow = WorkflowService(
repo,
transformation_service=TransformationService(repo, asset_service=registry),
)
workflow.register_template(review_gate_template(), context)
rejected = _invoke_review_workflow(workflow, context)
rejected_result = workflow.record_review_decision(
rejected.run.run_id,
rejected.run.review_tasks[0].review_id,
WorkflowReviewDecisionType.REJECT,
context,
note="incorrect output",
)
assert rejected_result.run.status == WorkflowRunStatus.FAILED
assert rejected_result.run.review_tasks[0].status == WorkflowReviewStatus.REJECTED
assert rejected_result.run.exceptions[0].status == WorkflowExceptionStatus.RESOLVED
corrected = _invoke_review_workflow(workflow, context)
corrected_result = workflow.record_review_decision(
corrected.run.run_id,
corrected.run.review_tasks[0].review_id,
WorkflowReviewDecisionType.CORRECT,
context,
note="minor metadata correction",
correction={"label": "approved-after-edit"},
)
assert corrected_result.run.status == WorkflowRunStatus.COMPLETED
assert corrected_result.run.review_tasks[0].status == WorkflowReviewStatus.CORRECTED
assert corrected_result.run.review_tasks[0].correction == {"label": "approved-after-edit"}
retry_requested = _invoke_review_workflow(workflow, context)
retry_result = workflow.record_review_decision(
retry_requested.run.run_id,
retry_requested.run.review_tasks[0].review_id,
WorkflowReviewDecisionType.RETRY,
context,
note="rerun with same inputs",
)
assert repo.get_workflow_run(retry_requested.run.run_id).status == WorkflowRunStatus.RETRIED
assert retry_result.run.retry_of_run_id == retry_requested.run.run_id
assert retry_result.run.status == WorkflowRunStatus.WAITING
assert retry_result.run.review_tasks[0].status == WorkflowReviewStatus.OPEN
escalated = _invoke_review_workflow(workflow, context)
escalated_result = workflow.record_review_decision(
escalated.run.run_id,
escalated.run.review_tasks[0].review_id,
WorkflowReviewDecisionType.ESCALATE,
context,
note="needs owner decision",
)
assert escalated_result.run.status == WorkflowRunStatus.WAITING
assert escalated_result.run.review_tasks[0].status == WorkflowReviewStatus.ESCALATED
assert escalated_result.run.exceptions[0].status == WorkflowExceptionStatus.ESCALATED
assert workflow.list_exception_queue(status=WorkflowExceptionStatus.ESCALATED)[0].exception_id == (
escalated_result.run.exceptions[0].exception_id
)
def test_failed_workflow_step_creates_exception_queue_item() -> None:
repo = InMemoryAssetRegistryRepository()
registry = AssetRegistryService(repo)
context = operation_context()
create_source_asset(registry, context, asset_id="asset-source")
workflow = WorkflowService(
repo,
transformation_service=TransformationService(repo, asset_service=registry),
)
workflow.register_template(markdown_failure_template(), context)
result = workflow.invoke_template(
WorkflowInvocation(
template_id="workflow-failure",
inputs={"source": {"kind": "asset", "asset_id": "asset-source"}},
),
context,
)
exceptions = workflow.list_exception_queue(kind=WorkflowExceptionKind.FAILED)
assert result.run.status == WorkflowRunStatus.FAILED
assert exceptions[0].workflow_run_id == result.run.run_id
assert exceptions[0].step_id == "markdown"
assert exceptions[0].diagnostics[0]["code"] == "transformation.operation_not_executable"
def test_sqlite_review_state_survives_reinstantiation(tmp_path: Path) -> None:
db_path = tmp_path / "registry.sqlite"
repo = SQLiteAssetRegistryRepository(db_path)
registry = AssetRegistryService(repo)
context = operation_context()
create_source_asset(registry, context, asset_id="asset-source")
workflow = WorkflowService(
repo,
transformation_service=TransformationService(repo, asset_service=registry),
)
workflow.register_template(review_gate_template(), context)
waiting = workflow.invoke_template(
WorkflowInvocation(
template_id="workflow-review",
inputs={"source": {"kind": "asset", "asset_id": "asset-source"}},
),
context,
)
reloaded = WorkflowService(SQLiteAssetRegistryRepository(db_path))
run = reloaded.reconstruct_run(waiting.run.run_id).run
assert run.status == WorkflowRunStatus.WAITING
assert run.review_tasks[0].status == WorkflowReviewStatus.OPEN
assert run.exceptions[0].kind == WorkflowExceptionKind.REVIEW_REQUIRED
def rich_input_template() -> WorkflowTemplate:
return WorkflowTemplate(
template_id="workflow-rich-inputs",
@@ -309,6 +487,56 @@ def partial_template() -> WorkflowTemplate:
)
def review_gate_template() -> WorkflowTemplate:
return WorkflowTemplate(
template_id="workflow-review",
name="Review Workflow",
inputs=(WorkflowInputDefinition("source", WorkflowInputKind.ASSET),),
steps=(
WorkflowStepDefinition(
step_id="view",
operation_id="structured_view",
inputs={"source_asset_ids": "$inputs.source"},
outputs={"asset_id": "asset-reviewed-output", "title": "Reviewed Output"},
review_gate={
"required": True,
"reason": "sensitive output requires approval",
"queue": "content-review",
},
),
),
)
def markdown_failure_template() -> WorkflowTemplate:
return WorkflowTemplate(
template_id="workflow-failure",
name="Failure Workflow",
inputs=(WorkflowInputDefinition("source", WorkflowInputKind.ASSET),),
steps=(
WorkflowStepDefinition(
step_id="markdown",
operation_id="markdown_transform",
inputs={"source_asset_ids": "$inputs.source"},
outputs={"asset_id": "asset-markdown-failure"},
),
),
)
def _invoke_review_workflow(
workflow: WorkflowService,
context: OperationContext,
):
return workflow.invoke_template(
WorkflowInvocation(
template_id="workflow-review",
inputs={"source": {"kind": "asset", "asset_id": "asset-source"}},
),
context,
)
def create_source_asset(
registry: AssetRegistryService,
context: OperationContext,