python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""ToolSpec-bound allow-listed primitive dispatcher."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from hwpx_automation.tool_contract import expected_tool_names
|
|
11
|
+
|
|
12
|
+
from .models import WorkFamily, WorkflowEvent, content_hash
|
|
13
|
+
from .policy import ActionRequest, PolicyViolation, WorkflowPolicyEngine
|
|
14
|
+
from .store import WorkflowStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
FAMILY_TOOL_ALLOWLISTS: dict[WorkFamily, frozenset[str]] = {
|
|
18
|
+
WorkFamily.READ_EXTRACT: frozenset(
|
|
19
|
+
{
|
|
20
|
+
"get_document_text",
|
|
21
|
+
"get_document_info",
|
|
22
|
+
"get_document_outline",
|
|
23
|
+
"get_document_map",
|
|
24
|
+
"hwpx_to_markdown",
|
|
25
|
+
"hwpx_extract_json",
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
WorkFamily.TRANSACTIONAL_EDIT: frozenset(
|
|
29
|
+
{
|
|
30
|
+
"get_document_info",
|
|
31
|
+
"apply_edits",
|
|
32
|
+
"undo_last_edit",
|
|
33
|
+
"doc_diff",
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
WorkFamily.KNOWN_TEMPLATE_FILL: frozenset(
|
|
37
|
+
{
|
|
38
|
+
"list_form_fields", "fill_form_field", "analyze_template_formfit",
|
|
39
|
+
"apply_template_formfit", "inspect_fill_residue", "verify_form_fill",
|
|
40
|
+
"scan_form_guidance", "apply_table_ops",
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
WorkFamily.UNKNOWN_FORM_FILL: frozenset(
|
|
44
|
+
{"scan_form_guidance", "apply_table_ops", "apply_body_ops", "inspect_fill_residue", "verify_form_fill"}
|
|
45
|
+
),
|
|
46
|
+
WorkFamily.STRUCTURAL_TABLE_EDIT: frozenset(
|
|
47
|
+
{"scan_form_guidance", "apply_table_ops", "doc_diff"}
|
|
48
|
+
),
|
|
49
|
+
WorkFamily.TYPED_AUTHORING: frozenset(
|
|
50
|
+
{
|
|
51
|
+
"get_document_plan_schema",
|
|
52
|
+
"validate_document_plan",
|
|
53
|
+
"analyze_document_plan",
|
|
54
|
+
"create_document_from_plan",
|
|
55
|
+
"inspect_document_authoring_quality",
|
|
56
|
+
"inspect_official_document_style",
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
WorkFamily.MUST_ABSTAIN: frozenset(),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class DispatchOutcome:
|
|
65
|
+
result: Any
|
|
66
|
+
replayed: bool
|
|
67
|
+
receipt: WorkflowEvent
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _result_evidence(result: Any) -> dict[str, Any]:
|
|
71
|
+
"""Extract only policy booleans; never persist tool result bodies."""
|
|
72
|
+
|
|
73
|
+
if not isinstance(result, Mapping):
|
|
74
|
+
return {"ok": True, "openSafetyOk": None}
|
|
75
|
+
ok = result.get("ok")
|
|
76
|
+
if ok is None:
|
|
77
|
+
ok = result.get("created")
|
|
78
|
+
if ok is None and isinstance(result.get("verificationReport"), Mapping):
|
|
79
|
+
ok = result["verificationReport"].get("ok")
|
|
80
|
+
open_safety = result.get("openSafety")
|
|
81
|
+
if not isinstance(open_safety, Mapping) and isinstance(result.get("verificationReport"), Mapping):
|
|
82
|
+
open_safety = result["verificationReport"].get("openSafety")
|
|
83
|
+
if not isinstance(open_safety, Mapping) and isinstance(result.get("verification"), Mapping):
|
|
84
|
+
open_safety = result["verification"].get("openSafety")
|
|
85
|
+
open_safety_ok = open_safety.get("ok") if isinstance(open_safety, Mapping) else None
|
|
86
|
+
return {"ok": ok is not False, "openSafetyOk": open_safety_ok}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AllowlistedDispatcher:
|
|
90
|
+
def __init__(self, namespace: Mapping[str, Callable[..., Any]]) -> None:
|
|
91
|
+
contract_names = expected_tool_names(advanced=True)
|
|
92
|
+
invalid = set().union(*FAMILY_TOOL_ALLOWLISTS.values()) - contract_names
|
|
93
|
+
if invalid:
|
|
94
|
+
raise RuntimeError(f"workflow allowlist contains tools absent from ToolSpec: {sorted(invalid)}")
|
|
95
|
+
self._namespace = namespace
|
|
96
|
+
|
|
97
|
+
def resolve(self, family: WorkFamily, action: ActionRequest) -> Callable[..., Any]:
|
|
98
|
+
if action.tool_name not in FAMILY_TOOL_ALLOWLISTS[family]:
|
|
99
|
+
raise PolicyViolation(
|
|
100
|
+
"TOOL_NOT_ALLOWED",
|
|
101
|
+
f"{action.tool_name} is not allowed for {family.value}",
|
|
102
|
+
details={"family": family.value, "tool": action.tool_name},
|
|
103
|
+
)
|
|
104
|
+
function = self._namespace.get(action.tool_name)
|
|
105
|
+
if not callable(function):
|
|
106
|
+
raise PolicyViolation(
|
|
107
|
+
"TOOL_UNAVAILABLE",
|
|
108
|
+
f"ToolSpec tool is not callable in the active server: {action.tool_name}",
|
|
109
|
+
)
|
|
110
|
+
return function
|
|
111
|
+
|
|
112
|
+
def dispatch(self, family: WorkFamily, action: ActionRequest) -> Any:
|
|
113
|
+
return self.resolve(family, action)(**dict(action.arguments))
|
|
114
|
+
|
|
115
|
+
def dispatch_durable(
|
|
116
|
+
self,
|
|
117
|
+
store: WorkflowStore,
|
|
118
|
+
workflow_id: str,
|
|
119
|
+
action: ActionRequest,
|
|
120
|
+
*,
|
|
121
|
+
expected_version: int,
|
|
122
|
+
capability_ok: bool,
|
|
123
|
+
policy: WorkflowPolicyEngine | None = None,
|
|
124
|
+
) -> DispatchOutcome:
|
|
125
|
+
"""Execute once behind durable started/completed/failed receipts.
|
|
126
|
+
|
|
127
|
+
A recovered completed action is not executed again. A recovered
|
|
128
|
+
``started`` action without a terminal receipt fails as in-doubt because
|
|
129
|
+
executing it again could duplicate a mutation.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
engine = policy or WorkflowPolicyEngine()
|
|
133
|
+
record = store.get(workflow_id)
|
|
134
|
+
events = store.events(workflow_id)
|
|
135
|
+
action_events = [event for event in events if event.payload.get("actionHash") == action.action_hash]
|
|
136
|
+
completed = next((event for event in reversed(action_events) if event.event_type == "dispatch.completed"), None)
|
|
137
|
+
if completed is not None:
|
|
138
|
+
# The primitive result is encrypted outside the append-only ledger.
|
|
139
|
+
# Replays must return the exact prior result so workflow planning and
|
|
140
|
+
# verification never depend on an in-memory first execution.
|
|
141
|
+
return DispatchOutcome(
|
|
142
|
+
result=store.get_action_result(workflow_id, action.action_hash),
|
|
143
|
+
replayed=True,
|
|
144
|
+
receipt=completed,
|
|
145
|
+
)
|
|
146
|
+
started_attempts = [event for event in action_events if event.event_type == "dispatch.started"]
|
|
147
|
+
terminal_attempts = {
|
|
148
|
+
int(event.payload["attempt"])
|
|
149
|
+
for event in action_events
|
|
150
|
+
if event.event_type in {"dispatch.completed", "dispatch.failed"} and "attempt" in event.payload
|
|
151
|
+
}
|
|
152
|
+
unresolved = next(
|
|
153
|
+
(event for event in reversed(started_attempts) if int(event.payload["attempt"]) not in terminal_attempts),
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
if unresolved is not None:
|
|
157
|
+
raise PolicyViolation(
|
|
158
|
+
"DISPATCH_IN_DOUBT",
|
|
159
|
+
"action has a durable started receipt but no terminal receipt; automatic re-execution is unsafe",
|
|
160
|
+
details={"actionHash": action.action_hash, "attempt": unresolved.payload["attempt"]},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if record.state_version != expected_version:
|
|
164
|
+
from .store import WorkflowConflict
|
|
165
|
+
|
|
166
|
+
raise WorkflowConflict(
|
|
167
|
+
f"stale workflow state: expected {record.state.value}@{expected_version}, "
|
|
168
|
+
f"actual {record.state.value}@{record.state_version}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
engine.authorize_action(record, action, events, capability_ok=capability_ok)
|
|
172
|
+
function = self.resolve(record.work_order.family, action)
|
|
173
|
+
attempt = len(started_attempts) + 1
|
|
174
|
+
common = {
|
|
175
|
+
"actionHash": action.action_hash,
|
|
176
|
+
"argumentsHash": content_hash(dict(action.arguments)),
|
|
177
|
+
"attempt": attempt,
|
|
178
|
+
"tool": action.tool_name,
|
|
179
|
+
}
|
|
180
|
+
running, started, _ = store.append_event(
|
|
181
|
+
workflow_id,
|
|
182
|
+
"dispatch.started",
|
|
183
|
+
expected_state=record.state,
|
|
184
|
+
expected_version=record.state_version,
|
|
185
|
+
payload=common,
|
|
186
|
+
event_key=f"dispatch:{action.action_hash}:{attempt}:started",
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
result = function(**dict(action.arguments))
|
|
190
|
+
except Exception as error:
|
|
191
|
+
store.append_event(
|
|
192
|
+
workflow_id,
|
|
193
|
+
"dispatch.failed",
|
|
194
|
+
expected_state=running.state,
|
|
195
|
+
expected_version=running.state_version,
|
|
196
|
+
payload={**common, "errorType": type(error).__name__},
|
|
197
|
+
event_key=f"dispatch:{action.action_hash}:{attempt}:failed",
|
|
198
|
+
)
|
|
199
|
+
raise
|
|
200
|
+
store.put_action_result(workflow_id, action.action_hash, result)
|
|
201
|
+
completed_record, completed_receipt, _ = store.append_event(
|
|
202
|
+
workflow_id,
|
|
203
|
+
"dispatch.completed",
|
|
204
|
+
expected_state=running.state,
|
|
205
|
+
expected_version=running.state_version,
|
|
206
|
+
payload={**common, "resultHash": content_hash(result), **_result_evidence(result)},
|
|
207
|
+
event_key=f"dispatch:{action.action_hash}:{attempt}:completed",
|
|
208
|
+
)
|
|
209
|
+
del completed_record, started
|
|
210
|
+
return DispatchOutcome(result=result, replayed=False, receipt=completed_receipt)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = ["AllowlistedDispatcher", "DispatchOutcome", "FAMILY_TOOL_ALLOWLISTS"]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Versioned contracts for durable server-side HWPX workflows.
|
|
3
|
+
|
|
4
|
+
These models deliberately accept typed work rather than a free-form prompt. Prompt
|
|
5
|
+
bodies belong to the MCP request boundary, not the durable event ledger.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
WORKFLOW_SCHEMA_VERSION = "hwpx.workflow.v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utc_now() -> datetime:
|
|
24
|
+
return datetime.now(timezone.utc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonical_json(value: Any) -> str:
|
|
28
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def content_hash(value: Any) -> str:
|
|
32
|
+
raw = value if isinstance(value, bytes) else canonical_json(value).encode("utf-8")
|
|
33
|
+
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SENSITIVE_LEDGER_KEY_PARTS = ("prompt", "instruction", "body", "text", "content", "bytes", "binary")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_ledger_payload(value: Any, *, key: str = "value") -> Any:
|
|
40
|
+
"""Remove prompt/document content before an event reaches durable storage."""
|
|
41
|
+
|
|
42
|
+
normalized_key = "".join(character for character in key.lower() if character.isalnum())
|
|
43
|
+
already_a_hash = normalized_key.endswith("hash") or normalized_key.endswith("revision")
|
|
44
|
+
if not already_a_hash and any(part in normalized_key for part in _SENSITIVE_LEDGER_KEY_PARTS):
|
|
45
|
+
return {"redacted": True, "hash": content_hash(value)}
|
|
46
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
47
|
+
return {"redacted": True, "hash": content_hash(bytes(value))}
|
|
48
|
+
if isinstance(value, Mapping):
|
|
49
|
+
return {str(nested_key): sanitize_ledger_payload(nested, key=str(nested_key)) for nested_key, nested in value.items()}
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return [sanitize_ledger_payload(nested, key=key) for nested in value]
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WorkFamily(str, Enum):
|
|
56
|
+
READ_EXTRACT = "read_extract"
|
|
57
|
+
TRANSACTIONAL_EDIT = "transactional_edit"
|
|
58
|
+
KNOWN_TEMPLATE_FILL = "known_template_fill"
|
|
59
|
+
UNKNOWN_FORM_FILL = "unknown_form_fill"
|
|
60
|
+
STRUCTURAL_TABLE_EDIT = "structural_table_edit"
|
|
61
|
+
TYPED_AUTHORING = "typed_authoring"
|
|
62
|
+
MUST_ABSTAIN = "must_abstain"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WorkflowState(str, Enum):
|
|
66
|
+
INTAKE = "intake"
|
|
67
|
+
RECON = "recon"
|
|
68
|
+
PLAN = "plan"
|
|
69
|
+
DECISION = "decision"
|
|
70
|
+
EXECUTE = "execute"
|
|
71
|
+
VERIFY = "verify"
|
|
72
|
+
REPAIR = "repair"
|
|
73
|
+
COMPLETED = "completed"
|
|
74
|
+
NEEDS_REVIEW = "needs_review"
|
|
75
|
+
FAILED = "failed"
|
|
76
|
+
CANCELLED = "cancelled"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
TERMINAL_STATES = frozenset(
|
|
80
|
+
{
|
|
81
|
+
WorkflowState.COMPLETED,
|
|
82
|
+
WorkflowState.NEEDS_REVIEW,
|
|
83
|
+
WorkflowState.FAILED,
|
|
84
|
+
WorkflowState.CANCELLED,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WorkflowBudget(BaseModel):
|
|
90
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
91
|
+
|
|
92
|
+
max_attempts: int = Field(default=3, ge=1, le=20)
|
|
93
|
+
max_tool_calls: int = Field(default=50, ge=1, le=1000)
|
|
94
|
+
max_elapsed_seconds: int = Field(default=900, ge=1, le=86_400)
|
|
95
|
+
max_repair_rounds: int = Field(default=3, ge=0, le=10)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WorkflowPolicy(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
100
|
+
|
|
101
|
+
preserve_original: bool = True
|
|
102
|
+
require_expected_revision: bool = True
|
|
103
|
+
require_decision_for_destructive: bool = True
|
|
104
|
+
require_open_safety: bool = True
|
|
105
|
+
require_verified_completion: bool = True
|
|
106
|
+
require_real_hancom_render: bool = False
|
|
107
|
+
|
|
108
|
+
@field_validator("preserve_original")
|
|
109
|
+
@classmethod
|
|
110
|
+
def original_preservation_cannot_be_disabled(cls, value: bool) -> bool:
|
|
111
|
+
if not value:
|
|
112
|
+
raise ValueError("server workflows always preserve the original document")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class WorkOrder(BaseModel):
|
|
117
|
+
"""Typed, restartable request accepted by the workflow engine.
|
|
118
|
+
|
|
119
|
+
``parameters`` may contain structured adapter input but never raw bytes. Event
|
|
120
|
+
records persist only its hash and key list, not its values.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
124
|
+
|
|
125
|
+
schema_version: str = WORKFLOW_SCHEMA_VERSION
|
|
126
|
+
family: WorkFamily
|
|
127
|
+
source_path: str | None = None
|
|
128
|
+
output_path: str | None = None
|
|
129
|
+
expected_revision: str | None = None
|
|
130
|
+
idempotency_key: str = Field(min_length=8, max_length=200)
|
|
131
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
132
|
+
budget: WorkflowBudget = Field(default_factory=WorkflowBudget)
|
|
133
|
+
policy: WorkflowPolicy = Field(default_factory=WorkflowPolicy)
|
|
134
|
+
|
|
135
|
+
@field_validator("schema_version")
|
|
136
|
+
@classmethod
|
|
137
|
+
def supported_schema(cls, value: str) -> str:
|
|
138
|
+
if value != WORKFLOW_SCHEMA_VERSION:
|
|
139
|
+
raise ValueError(f"unsupported workflow schema: {value}")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
@field_validator("source_path", "output_path")
|
|
143
|
+
@classmethod
|
|
144
|
+
def normalize_path(cls, value: str | None) -> str | None:
|
|
145
|
+
return str(Path(value).expanduser().resolve()) if value else None
|
|
146
|
+
|
|
147
|
+
@field_validator("parameters")
|
|
148
|
+
@classmethod
|
|
149
|
+
def reject_binary_parameters(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
def walk(item: Any) -> None:
|
|
151
|
+
if isinstance(item, (bytes, bytearray, memoryview)):
|
|
152
|
+
raise ValueError("document bytes are not accepted in workflow parameters")
|
|
153
|
+
if isinstance(item, Mapping):
|
|
154
|
+
for nested in item.values():
|
|
155
|
+
walk(nested)
|
|
156
|
+
elif isinstance(item, (list, tuple)):
|
|
157
|
+
for nested in item:
|
|
158
|
+
walk(nested)
|
|
159
|
+
|
|
160
|
+
walk(value)
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
def fingerprint(self) -> str:
|
|
164
|
+
return content_hash(self.model_dump(mode="json"))
|
|
165
|
+
|
|
166
|
+
def ledger_summary(self) -> dict[str, Any]:
|
|
167
|
+
"""Return the non-content request summary safe for append-only events."""
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"schemaVersion": self.schema_version,
|
|
171
|
+
"family": self.family.value,
|
|
172
|
+
"sourcePathHash": content_hash(self.source_path) if self.source_path else None,
|
|
173
|
+
"outputPathHash": content_hash(self.output_path) if self.output_path else None,
|
|
174
|
+
"expectedRevision": self.expected_revision,
|
|
175
|
+
"parameterKeys": sorted(self.parameters),
|
|
176
|
+
"parametersHash": content_hash(self.parameters),
|
|
177
|
+
"workOrderHash": self.fingerprint(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class WorkflowEvent(BaseModel):
|
|
182
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
183
|
+
|
|
184
|
+
workflow_id: str
|
|
185
|
+
index: int = Field(ge=0)
|
|
186
|
+
event_type: str
|
|
187
|
+
from_state: WorkflowState | None
|
|
188
|
+
to_state: WorkflowState
|
|
189
|
+
occurred_at: datetime
|
|
190
|
+
payload: dict[str, Any]
|
|
191
|
+
previous_hash: str | None
|
|
192
|
+
event_hash: str
|
|
193
|
+
event_key: str | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class WorkflowRecord(BaseModel):
|
|
197
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
198
|
+
|
|
199
|
+
workflow_id: str
|
|
200
|
+
work_order: WorkOrder
|
|
201
|
+
state: WorkflowState
|
|
202
|
+
state_version: int = Field(ge=1)
|
|
203
|
+
created_at: datetime
|
|
204
|
+
updated_at: datetime
|
|
205
|
+
original_content_hash: str | None = None
|
|
206
|
+
output_content_hash: str | None = None
|
|
207
|
+
stop_reason: str | None = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def terminal(self) -> bool:
|
|
211
|
+
return self.state in TERMINAL_STATES
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ActionResult(BaseModel):
|
|
215
|
+
"""Decrypted durable result plus non-sensitive retention metadata."""
|
|
216
|
+
|
|
217
|
+
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
|
218
|
+
|
|
219
|
+
workflow_id: str = Field(serialization_alias="workflowId")
|
|
220
|
+
action_hash: str = Field(serialization_alias="actionHash")
|
|
221
|
+
result: Any
|
|
222
|
+
content_hash: str = Field(serialization_alias="contentHash")
|
|
223
|
+
size_bytes: int = Field(ge=0, serialization_alias="sizeBytes")
|
|
224
|
+
created_at: datetime = Field(serialization_alias="createdAt")
|
|
225
|
+
expires_at: datetime = Field(serialization_alias="expiresAt")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = [
|
|
229
|
+
"TERMINAL_STATES",
|
|
230
|
+
"WORKFLOW_SCHEMA_VERSION",
|
|
231
|
+
"ActionResult",
|
|
232
|
+
"WorkFamily",
|
|
233
|
+
"WorkOrder",
|
|
234
|
+
"WorkflowBudget",
|
|
235
|
+
"WorkflowEvent",
|
|
236
|
+
"WorkflowPolicy",
|
|
237
|
+
"WorkflowRecord",
|
|
238
|
+
"WorkflowState",
|
|
239
|
+
"canonical_json",
|
|
240
|
+
"content_hash",
|
|
241
|
+
"sanitize_ledger_payload",
|
|
242
|
+
"utc_now",
|
|
243
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Fail-closed policy decisions for durable HWPX workflows."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Iterable, Mapping
|
|
8
|
+
|
|
9
|
+
from hwpx_automation.document_state import document_revision
|
|
10
|
+
|
|
11
|
+
from .models import WorkFamily, WorkflowEvent, WorkflowRecord, WorkflowState, content_hash, utc_now
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MUTATING_FAMILIES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
WorkFamily.TRANSACTIONAL_EDIT,
|
|
17
|
+
WorkFamily.KNOWN_TEMPLATE_FILL,
|
|
18
|
+
WorkFamily.UNKNOWN_FORM_FILL,
|
|
19
|
+
WorkFamily.STRUCTURAL_TABLE_EDIT,
|
|
20
|
+
WorkFamily.TYPED_AUTHORING,
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PolicyViolation(RuntimeError):
|
|
26
|
+
def __init__(self, code: str, message: str, *, details: Mapping[str, Any] | None = None) -> None:
|
|
27
|
+
self.code = code
|
|
28
|
+
self.details = dict(details or {})
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {"ok": False, "code": self.code, "message": str(self), "details": self.details}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ActionRequest:
|
|
37
|
+
tool_name: str
|
|
38
|
+
arguments: Mapping[str, Any]
|
|
39
|
+
destructive: bool = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def action_hash(self) -> str:
|
|
43
|
+
return content_hash(
|
|
44
|
+
{"tool": self.tool_name, "arguments": self.arguments, "destructive": self.destructive}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WorkflowPolicyEngine:
|
|
49
|
+
"""Evaluate invariants using durable record/event evidence only."""
|
|
50
|
+
|
|
51
|
+
def validate_intake(self, record: WorkflowRecord) -> None:
|
|
52
|
+
order = record.work_order
|
|
53
|
+
if order.family not in MUTATING_FAMILIES:
|
|
54
|
+
return
|
|
55
|
+
if order.source_path and order.output_path and order.source_path == order.output_path:
|
|
56
|
+
raise PolicyViolation(
|
|
57
|
+
"ORIGINAL_OVERWRITE_FORBIDDEN",
|
|
58
|
+
"workflow output must be a distinct copy; the original cannot be overwritten",
|
|
59
|
+
)
|
|
60
|
+
if order.family != WorkFamily.TYPED_AUTHORING and not order.source_path:
|
|
61
|
+
raise PolicyViolation("SOURCE_REQUIRED", "this workflow family requires a source document")
|
|
62
|
+
if not order.output_path:
|
|
63
|
+
raise PolicyViolation("OUTPUT_COPY_REQUIRED", "mutating workflows require a distinct output path")
|
|
64
|
+
if order.policy.require_expected_revision and order.source_path and not order.expected_revision:
|
|
65
|
+
raise PolicyViolation("EXPECTED_REVISION_REQUIRED", "expected_revision is required before mutation")
|
|
66
|
+
|
|
67
|
+
def authorize_action(
|
|
68
|
+
self,
|
|
69
|
+
record: WorkflowRecord,
|
|
70
|
+
action: ActionRequest,
|
|
71
|
+
events: Iterable[WorkflowEvent],
|
|
72
|
+
*,
|
|
73
|
+
capability_ok: bool,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.validate_intake(record)
|
|
76
|
+
if not capability_ok:
|
|
77
|
+
raise PolicyViolation("CAPABILITY_SKEW", "workflow dispatch is disabled by capability/version skew")
|
|
78
|
+
if record.terminal:
|
|
79
|
+
raise PolicyViolation("WORKFLOW_TERMINAL", "terminal workflows cannot dispatch more tools")
|
|
80
|
+
|
|
81
|
+
event_list = list(events)
|
|
82
|
+
elapsed = (utc_now() - record.created_at).total_seconds()
|
|
83
|
+
if elapsed >= record.work_order.budget.max_elapsed_seconds:
|
|
84
|
+
raise PolicyViolation(
|
|
85
|
+
"ELAPSED_TIME_BUDGET_EXCEEDED",
|
|
86
|
+
"workflow elapsed-time budget is exhausted",
|
|
87
|
+
details={"elapsedSeconds": int(elapsed), "maximum": record.work_order.budget.max_elapsed_seconds},
|
|
88
|
+
)
|
|
89
|
+
calls = sum(event.event_type == "dispatch.started" for event in event_list)
|
|
90
|
+
if calls >= record.work_order.budget.max_tool_calls:
|
|
91
|
+
raise PolicyViolation(
|
|
92
|
+
"TOOL_CALL_BUDGET_EXCEEDED",
|
|
93
|
+
"workflow tool-call budget is exhausted",
|
|
94
|
+
details={"used": calls, "maximum": record.work_order.budget.max_tool_calls},
|
|
95
|
+
)
|
|
96
|
+
attempts = sum(
|
|
97
|
+
event.event_type == "dispatch.started" and event.payload.get("actionHash") == action.action_hash
|
|
98
|
+
for event in event_list
|
|
99
|
+
)
|
|
100
|
+
if attempts >= record.work_order.budget.max_attempts:
|
|
101
|
+
raise PolicyViolation(
|
|
102
|
+
"ACTION_ATTEMPT_BUDGET_EXCEEDED",
|
|
103
|
+
"action attempt budget is exhausted",
|
|
104
|
+
details={"used": attempts, "maximum": record.work_order.budget.max_attempts},
|
|
105
|
+
)
|
|
106
|
+
repair_rounds = sum(
|
|
107
|
+
event.from_state != WorkflowState.REPAIR and event.to_state == WorkflowState.REPAIR
|
|
108
|
+
for event in event_list
|
|
109
|
+
)
|
|
110
|
+
if repair_rounds > record.work_order.budget.max_repair_rounds:
|
|
111
|
+
raise PolicyViolation(
|
|
112
|
+
"REPAIR_BUDGET_EXCEEDED",
|
|
113
|
+
"workflow repair-round budget is exhausted",
|
|
114
|
+
details={"used": repair_rounds, "maximum": record.work_order.budget.max_repair_rounds},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
order = record.work_order
|
|
118
|
+
if order.source_path and order.expected_revision:
|
|
119
|
+
actual = document_revision(order.source_path)
|
|
120
|
+
if actual != order.expected_revision:
|
|
121
|
+
raise PolicyViolation(
|
|
122
|
+
"STALE_DOCUMENT_REVISION",
|
|
123
|
+
"source document changed after workflow intake",
|
|
124
|
+
details={"expected": order.expected_revision, "actual": actual},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if action.destructive and order.policy.require_decision_for_destructive:
|
|
128
|
+
approved = any(
|
|
129
|
+
event.event_type == "decision.approved"
|
|
130
|
+
and event.from_state == WorkflowState.DECISION
|
|
131
|
+
and event.to_state == WorkflowState.DECISION
|
|
132
|
+
and event.payload.get("actionHash") == action.action_hash
|
|
133
|
+
for event in event_list
|
|
134
|
+
)
|
|
135
|
+
if not approved:
|
|
136
|
+
raise PolicyViolation(
|
|
137
|
+
"DECISION_REQUIRED",
|
|
138
|
+
"this action requires an explicit matching decision receipt",
|
|
139
|
+
details={"actionHash": action.action_hash},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def authorize_completion(self, record: WorkflowRecord, verification: Mapping[str, Any]) -> None:
|
|
143
|
+
if record.state != WorkflowState.VERIFY:
|
|
144
|
+
raise PolicyViolation("VERIFY_STATE_REQUIRED", "completion is only allowed from verify state")
|
|
145
|
+
if record.work_order.policy.require_open_safety and verification.get("openSafetyOk") is not True:
|
|
146
|
+
raise PolicyViolation("OPEN_SAFETY_REQUIRED", "openSafety evidence is required for completion")
|
|
147
|
+
if record.work_order.policy.require_verified_completion and verification.get("verified") is not True:
|
|
148
|
+
raise PolicyViolation("VERIFIED_COMPLETION_REQUIRED", "unverified work cannot be marked completed")
|
|
149
|
+
if verification.get("domainVerified") is not True:
|
|
150
|
+
raise PolicyViolation(
|
|
151
|
+
"DOMAIN_VERIFICATION_REQUIRED",
|
|
152
|
+
"family-specific result and quality verification are required for completion",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def record_decision(
|
|
156
|
+
self,
|
|
157
|
+
store: Any,
|
|
158
|
+
record: WorkflowRecord,
|
|
159
|
+
action: ActionRequest,
|
|
160
|
+
*,
|
|
161
|
+
approved: bool,
|
|
162
|
+
) -> WorkflowRecord:
|
|
163
|
+
if record.state != WorkflowState.DECISION:
|
|
164
|
+
raise PolicyViolation("DECISION_STATE_REQUIRED", "decision receipts are only valid in decision state")
|
|
165
|
+
event_type = "decision.approved" if approved else "decision.rejected"
|
|
166
|
+
updated, _, _ = store.append_event(
|
|
167
|
+
record.workflow_id,
|
|
168
|
+
event_type,
|
|
169
|
+
expected_state=record.state,
|
|
170
|
+
expected_version=record.state_version,
|
|
171
|
+
payload={"actionHash": action.action_hash, "approved": approved},
|
|
172
|
+
event_key=f"decision:{action.action_hash}",
|
|
173
|
+
)
|
|
174
|
+
return updated
|
|
175
|
+
|
|
176
|
+
def complete(
|
|
177
|
+
self,
|
|
178
|
+
store: Any,
|
|
179
|
+
record: WorkflowRecord,
|
|
180
|
+
verification: Mapping[str, Any],
|
|
181
|
+
*,
|
|
182
|
+
output_content_hash: str | None = None,
|
|
183
|
+
) -> WorkflowRecord:
|
|
184
|
+
"""Policy-gated only path from VERIFY to COMPLETED."""
|
|
185
|
+
|
|
186
|
+
self.authorize_completion(record, verification)
|
|
187
|
+
return store.transition(
|
|
188
|
+
record.workflow_id,
|
|
189
|
+
WorkflowState.COMPLETED,
|
|
190
|
+
expected_state=record.state,
|
|
191
|
+
expected_version=record.state_version,
|
|
192
|
+
event_type="workflow.completed",
|
|
193
|
+
payload={"verification": dict(verification)},
|
|
194
|
+
output_content_hash=output_content_hash,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = ["ActionRequest", "MUTATING_FAMILIES", "PolicyViolation", "WorkflowPolicyEngine"]
|