generated from coulomb/repo-seed
Workflow layer: gates, decisions, lineage audits, workflow test
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user